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,185 @@
1
+ export function formatDuration(milliseconds) {
2
+ const ms = Number(milliseconds);
3
+ if (!Number.isFinite(ms) || ms < 0) return "";
4
+
5
+ if (ms < 500) return `${Math.round(ms)}ms`;
6
+
7
+ const seconds = ms / 1000;
8
+ if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
9
+
10
+ const totalSeconds = Math.round(seconds);
11
+ const minutes = Math.floor(totalSeconds / 60);
12
+ return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
13
+ }
14
+
15
+ // A turn's timing line: "turn 3 · 4.1s", or "turn 3 running · 1.0s" while
16
+ // it runs. The number is the turn's place in the session's turn records,
17
+ // the one a reloaded history shows.
18
+ export function turnTimingText(number, durationMs, { running = false, canceled = false } = {}) {
19
+ const turn = Number.isInteger(number) && number > 0 ? `turn ${number}` : "turn";
20
+ return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
21
+ }
22
+
23
+ // The line a canceled turn ends with: "✕ canceled (user)", live from
24
+ // turn_canceled's reason and after a re-render from the turn record's
25
+ // cancellation_reason (records saved before it existed: no reason).
26
+ export function cancelLineText(reason) {
27
+ return "\u2715 canceled" + (reason ? ` (${reason})` : "");
28
+ }
29
+
30
+ // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
31
+ // record is not a canceled one.
32
+ export function cancelLineHtml(record, escape) {
33
+ if (record?.status !== "canceled") return "";
34
+ return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason))}</div>`;
35
+ }
36
+
37
+ export function elapsedSince(startedAt, now = Date.now()) {
38
+ const started = Date.parse(startedAt);
39
+ const current = Number(now);
40
+ if (!Number.isFinite(started) || !Number.isFinite(current)) return null;
41
+ return Math.max(0, current - started);
42
+ }
43
+
44
+ export function normalizeTiming(data = {}) {
45
+ return {
46
+ startedAt: typeof data.started_at === "string" ? data.started_at : null,
47
+ sessionDurationMs: finiteOrNull(data.session_duration_ms),
48
+ turnRecords: Array.isArray(data.turn_records) ? data.turn_records.filter(validRecord) : [],
49
+ toolRecords: Array.isArray(data.tool_records) ? data.tool_records.filter(validRecord) : [],
50
+ activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
51
+ };
52
+ }
53
+
54
+ export function turnRecordAt(timing, index) {
55
+ const records = timing?.turnRecords;
56
+ return Array.isArray(records) ? records[index] || null : null;
57
+ }
58
+
59
+ // For each history item, the turn index its timing shows under, or null. A
60
+ // turn with tool calls holds several assistant messages; only the last one
61
+ // before the next user message carries the turn's timing. A turn with none
62
+ // (canceled before an answer, or still running: no record yet) has it on
63
+ // its user message.
64
+ export function timedTurnIndexes(items) {
65
+ const result = items.map(() => null);
66
+ let turnIndex = -1;
67
+ let carrier = null;
68
+ items.forEach((item, i) => {
69
+ if (item?.role === "user") {
70
+ if (carrier !== null) result[carrier] = turnIndex;
71
+ turnIndex += 1;
72
+ carrier = i;
73
+ } else if (item?.role === "assistant") {
74
+ carrier = i;
75
+ }
76
+ });
77
+ if (carrier !== null) result[carrier] = turnIndex;
78
+ return result;
79
+ }
80
+
81
+ // The history as turns, for the turn view's reload: notes on their own, and
82
+ // per prompt a turn with its record, its steps (one per iteration: the
83
+ // turn's tool records grouped by iteration, and the assistant messages but
84
+ // the last, in order; a text-less iteration leaves no message, so a text
85
+ // can sit one step early: inspection, not truth) and its answer (the last
86
+ // assistant message). Assistant messages before any prompt are answers on
87
+ // their own. The turn index counts prompts, as timedTurnIndexes does.
88
+ export function turnGroups(items, timing) {
89
+ const groups = [];
90
+ let turnIndex = -1;
91
+ let turn = null;
92
+ items.forEach((item, i) => {
93
+ if (item?.role === "note") {
94
+ groups.push({ kind: "note", i });
95
+ } else if (item?.role === "user") {
96
+ turnIndex += 1;
97
+ turn = { kind: "turn", turnIndex, user: i, texts: [] };
98
+ groups.push(turn);
99
+ } else if (item?.role === "assistant") {
100
+ if (turn) turn.texts.push(i);
101
+ else groups.push({ kind: "answer", answer: i });
102
+ }
103
+ });
104
+ return groups.map((g) => {
105
+ if (g.kind !== "turn") return g;
106
+ const record = turnRecordAt(timing, g.turnIndex);
107
+ const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
108
+ const stepsOnly = canceledSteps(g, record, tools);
109
+ if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
110
+ const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
111
+ const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
112
+ const steps = [];
113
+ for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
114
+ const iteration = k + 1;
115
+ steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
116
+ }
117
+ const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
118
+ return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
119
+ });
120
+ }
121
+
122
+ // A canceled turn that ran more than one step or any call has no answer:
123
+ // its partial text stays in the block, as it does live (turn_view.js
124
+ // turnEnded promotes only a completed turn or a lone step with no tools).
125
+ function canceledSteps(g, record, tools) {
126
+ return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
127
+ }
128
+
129
+ function recordRows(tools, iteration) {
130
+ return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
131
+ key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
132
+ }));
133
+ }
134
+
135
+ // A turn whose saved messages carry their parts (the server's ?parts=1):
136
+ // every assistant message is one generation, so the k-th is iteration k
137
+ // and its calls pair with that iteration's records by position. The last
138
+ // message is the answer when it has text and made no call (and the turn
139
+ // is no canceled multi-step one); its thinking stays behind as a step, as
140
+ // it does live.
141
+ function partsTurn(g, items, record, tools, stepsOnly = false) {
142
+ const last = g.texts[g.texts.length - 1];
143
+ const lastItem = items[last];
144
+ const answer = !stepsOnly && String(lastItem.content ?? "") !== "" && !lastItem.parts?.tools?.length ? last : null;
145
+ const steps = g.texts.map((i, k) => {
146
+ const iteration = k + 1;
147
+ const parts = items[i].parts || {};
148
+ const records = recordRows(tools, iteration);
149
+ const rows = Array.isArray(parts.tools)
150
+ ? parts.tools.map((call, j) => {
151
+ const r = records.find((row) => row.key === `${iteration}:${j + 1}`);
152
+ return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
153
+ })
154
+ : records;
155
+ const thinking = String(parts.thinking || "");
156
+ if (i === answer) return thinking ? { i: null, iteration, thinking, tools: [] } : null;
157
+ return { i, iteration, thinking, tools: rows };
158
+ }).filter(Boolean);
159
+ // Records past the saved messages (a generation that saved nothing).
160
+ const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
161
+ for (let iteration = g.texts.length + 1; iteration <= maxIteration; iteration += 1) {
162
+ steps.push({ i: null, iteration, thinking: "", tools: recordRows(tools, iteration) });
163
+ }
164
+ return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
165
+ }
166
+
167
+ // Append +el+ to the history, above +timingEl+ while it is a running turn's
168
+ // live timing line, so that line stays under the turn's activity, thinking
169
+ // and answer.
170
+ export function appendAboveLiveTiming(parent, el, timingEl) {
171
+ if (timingEl && timingEl.parentNode === parent && timingEl.classList.contains("live")) {
172
+ parent.insertBefore(el, timingEl);
173
+ } else {
174
+ parent.appendChild(el);
175
+ }
176
+ }
177
+
178
+ function validRecord(record) {
179
+ return record && typeof record === "object";
180
+ }
181
+
182
+ function finiteOrNull(value) {
183
+ const number = Number(value);
184
+ return Number.isFinite(number) && number >= 0 ? number : null;
185
+ }
@@ -0,0 +1,209 @@
1
+ // Pure helpers for the Bridge's turn events (no DOM), so app.js's handlers
2
+ // stay thin and the logic runs under `node --test`.
3
+
4
+ // The answer text of a :turn_completed event. `turn_summary.output` is the
5
+ // contract; `result` is the KernelLoop result, which reaches the wire as its
6
+ // string form (older servers, other clients' fixtures).
7
+ export function turnOutput(data) {
8
+ if (!data) return "";
9
+ const summary = data.turn_summary?.output;
10
+ const result = typeof data.result === "string" ? data.result : data.result?.output;
11
+ const out = [summary, result, data.content, data.output].find((v) => typeof v === "string" && v.trim());
12
+ return out ? out.trim() : "";
13
+ }
14
+
15
+ // The end line for a turn whose worker went away mid-turn (the footer's
16
+ // stop, a crash): no turn_canceled comes, so the page ends the turn itself.
17
+ // null when no turn was running.
18
+ export function workerGoneText({ turnRunning, stopped = false }) {
19
+ if (!turnRunning) return null;
20
+ return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
21
+ }
22
+
23
+ // This tab's id in the events (`origin.client_id`, turn_enqueued's
24
+ // client_id): new per page load, like the TUI's `tui:<pid>`.
25
+ export function newClientId() {
26
+ return `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
27
+ }
28
+
29
+ export function isOwn(clientId, myId) {
30
+ return clientId != null && clientId === myId;
31
+ }
32
+
33
+ const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
34
+
35
+ // The label on another client's prompt, by its client_id prefix.
36
+ export function clientLabel(clientId) {
37
+ if (!clientId) return null;
38
+ return CLIENT_LABELS[String(clientId).split(":", 1)[0]] || "user";
39
+ }
40
+
41
+ // The note for reminders a turn got (a reminder turn has no prompt).
42
+ export function reminderText(event) {
43
+ const names = (event?.reminders || []).map((r) => (r && typeof r === "object" ? r.name : r)).filter(Boolean);
44
+ return names.length ? `reminder: ${names.join(", ")}` : "reminder";
45
+ }
46
+
47
+ // Who a hook's notice is from: the bundle's name for a bundle hook
48
+ // ("known_names.rb (bundle known-names)"), else "hook".
49
+ export function hookNoticeLabel(hook) {
50
+ const m = /\(bundle (.+)\)$/.exec(String(hook || ""));
51
+ return m ? m[1] : "hook";
52
+ }
53
+
54
+ // A composer line for the worker's command route rather than a prompt.
55
+ export function isCommandLine(text) {
56
+ return /^[/!]/.test(String(text || "").trim());
57
+ }
58
+
59
+ // The continue card's answers, as the terminal's continue prompt reads them.
60
+ export function continueLine(choice, reason = "") {
61
+ const why = String(reason || "").trim();
62
+ if (choice === "no" && why) return `/continue no, ${why}`;
63
+ return `/continue ${choice}`;
64
+ }
65
+
66
+ // How to show a command_ran: `label` names another client (null for this
67
+ // tab's own), `resync` says the conversation changed (!rollback, !cmd, a
68
+ // continue answered no) and must be read again. An anytime command's
69
+ // (`anytime`, D8) bubble is drawn at its command_queued, before the cards
70
+ // it shows; its command_ran fills that bubble in (`commandId`).
71
+ export function commandView(event, myId) {
72
+ const own = isOwn(event.client_id, myId);
73
+ return {
74
+ label: own ? null : clientLabel(event.client_id),
75
+ line: event.line || "",
76
+ text: event.output || "",
77
+ busy: event.status === "busy",
78
+ failed: event.status === "error",
79
+ resync: (event.changed || []).includes("messages"),
80
+ modelName: event.model_name || null,
81
+ anytime: event.anytime === true,
82
+ commandId: event.command_id || null,
83
+ };
84
+ }
85
+
86
+ // What a prompt_restored (the worker rolled a failed turn back and handed its
87
+ // prompt back) does in this tab: `refill` is the text for an empty composer,
88
+ // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
89
+ // replay after a reload never refills stale text; `own`/`label` mark the
90
+ // failed bubble shown again after the resync.
91
+ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
92
+ const origin = event?.origin || {};
93
+ const own = isOwn(origin.client_id, myId);
94
+ const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
95
+ // The images go back as chips with the text.
96
+ return withImages({
97
+ refill: sent && composerEmpty ? event.prompt : null,
98
+ own,
99
+ label: own ? null : clientLabel(origin.client_id),
100
+ }, sent && composerEmpty ? event.images : null);
101
+ }
102
+
103
+ // What an event does to the prompt bubbles, as a list of ops for app.js:
104
+ // add — a bubble for a prompt this tab hasn't shown (state queued /
105
+ // started / steered, labelled by its sender)
106
+ // tag — this tab's own local echo gets its enqueued_id
107
+ // start — the bubble's turn started (drops the "queued" badge)
108
+ // steer — the bubble was merged into the running turn
109
+ // `known(enqueuedId)` says whether a bubble carries that id already;
110
+ // `unmatchedMerge` whether the last input_merged had an origin with no bubble.
111
+ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
112
+ if (!event) return [];
113
+ switch (event.type) {
114
+ case "turn_enqueued": {
115
+ const id = event.enqueued_id;
116
+ if (isOwn(event.client_id, myId)) return [withImages({ op: "tag", enqueuedId: id, prompt: event.prompt }, event.images)];
117
+ if (id && known(id)) return [];
118
+ return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "queued", label: clientLabel(event.client_id) }, event.images)];
119
+ }
120
+ case "turn_started": {
121
+ if (event.continue || !event.prompt) return [];
122
+ const origin = event.origin || {};
123
+ const id = origin.enqueued_id || null;
124
+ if ((id && known(id)) || isOwn(origin.client_id, myId)) {
125
+ // Images too: a resync can have wiped the own echo, and the page then
126
+ // draws the bubble from this.
127
+ return [withImages({ op: "start", enqueuedId: id, prompt: event.prompt }, event.images)];
128
+ }
129
+ return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
130
+ }
131
+ case "input_merged":
132
+ return (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
133
+ known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
134
+ );
135
+ case "pending_input_merged":
136
+ if (!unmatchedMerge || !event.content) return [];
137
+ return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
138
+ default:
139
+ return [];
140
+ }
141
+ }
142
+
143
+ // A session view from the server (GET /api/sessions/:id on a live worker)
144
+ // as the live events that would have drawn it: the turn in progress (its
145
+ // parts in order), then the prompts queued behind it. app.js feeds these to
146
+ // its stream handlers, so a join renders exactly like watching live.
147
+ // `merged_input` is the one synthetic type: a prompt merged into the turn.
148
+ export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
149
+ const events = [];
150
+ if (turn) {
151
+ events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at }, turn.images));
152
+ let textIteration = null; // a text bubble is open for this iteration
153
+ const closeText = () => {
154
+ if (textIteration !== null) events.push({ type: "generation_completed" });
155
+ textIteration = null;
156
+ };
157
+ for (const part of turn.parts || []) {
158
+ switch (part.kind) {
159
+ // The chunks carry the part's iteration, so the turn view groups a
160
+ // join like the live stream (turn_model.js).
161
+ case "thinking":
162
+ events.push({ type: "generation_chunk", text: "", thinking: part.text, iteration: part.iteration ?? null });
163
+ break;
164
+ case "text":
165
+ if (textIteration !== null && textIteration !== part.iteration) closeText();
166
+ textIteration = part.iteration;
167
+ events.push({ type: "generation_chunk", text: part.text, thinking: "", iteration: part.iteration ?? null });
168
+ break;
169
+ case "tool": {
170
+ closeText();
171
+ const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
172
+ if (part.label) call.label = part.label;
173
+ events.push({ type: "tool_call_started", ...call, params: part.params });
174
+ if (part.status !== "running") {
175
+ events.push(withImages({
176
+ type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
177
+ activity: { status: part.status, params: part.params },
178
+ }, part.images));
179
+ }
180
+ break;
181
+ }
182
+ case "input":
183
+ closeText();
184
+ events.push({ type: "merged_input", content: part.text, origins: part.origins || [] });
185
+ break;
186
+ case "reminder":
187
+ closeText();
188
+ events.push({ type: "reminder_injected", reminders: part.reminders || [] });
189
+ break;
190
+ case "hook_notice":
191
+ closeText();
192
+ events.push({ type: "hook_notice", hook: part.hook, text: part.text, level: part.level });
193
+ break;
194
+ default:
195
+ break;
196
+ }
197
+ }
198
+ }
199
+ for (const entry of queued || []) {
200
+ events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt }, entry.images));
201
+ }
202
+ return events;
203
+ }
204
+
205
+ // +object+ with `images` when there are any (the shapes stay as they were
206
+ // for a turn without).
207
+ function withImages(object, images) {
208
+ return images?.length ? { ...object, images } : object;
209
+ }
@@ -0,0 +1,204 @@
1
+ // The generations of one turn, as the turn view shows them: a list of gens
2
+ // (one per model generation: its thinking, its narration, its tool rows),
3
+ // the last one live until the next opens or the turn ends. Pure: fed by the
4
+ // same events live and on a join (turn_events.js snapshotEvents), no DOM, so
5
+ // `node --test` covers the grouping rules. turn_view.js draws it.
6
+ //
7
+ // Rules:
8
+ // - turn_started opens the first gen (no iteration yet).
9
+ // - generation_started(n) opens a new gen unless the current one is still
10
+ // empty (then it takes n).
11
+ // - a chunk goes to the gen of its iteration (opening one when none has it);
12
+ // a chunk with no iteration goes to the current gen, unless that one is
13
+ // textDone or already has tool rows (an older server's replay), which
14
+ // opens a new gen.
15
+ // - generation_completed marks the current gen textDone; its tool rows
16
+ // follow it (the kernel emits them after).
17
+ // - tool events go to the gen of their iteration, else to the current gen,
18
+ // which takes the iteration when it had none.
19
+ // - a hook's notice goes to the current gen (hook_notice has no
20
+ // iteration; a before_tool_call hook runs before the call's
21
+ // tool_call_started), none once the turn ended.
22
+ // - the turn's end closes the current gen.
23
+ import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
24
+ import { tallyText } from "./tally.js";
25
+ import { leadTrimmed } from "./format.js";
26
+
27
+ const LABEL_MAX = 80;
28
+ const LABEL_TOOLS = 3;
29
+
30
+ export function newTurn() {
31
+ return { gens: [], ended: false, plainAnswer: false };
32
+ }
33
+
34
+ // The gen still open, or null.
35
+ export function currentGen(turn) {
36
+ const last = turn.gens[turn.gens.length - 1];
37
+ return last && !last.closed ? last : null;
38
+ }
39
+
40
+ function isEmpty(gen) {
41
+ return !gen.thinking && !gen.text && gen.tools.length === 0;
42
+ }
43
+
44
+ function openGen(turn, iteration) {
45
+ const closed = closeCurrent(turn);
46
+ const gen = { iteration: iteration ?? null, thinking: "", text: "", activity: newActivity(), textDone: false, closed: false, notices: [] };
47
+ gen.tools = gen.activity.rows;
48
+ turn.gens.push(gen);
49
+ return { gen, opened: true, closed };
50
+ }
51
+
52
+ function closeCurrent(turn) {
53
+ const gen = currentGen(turn);
54
+ if (!gen) return null;
55
+ gen.thinking = gen.thinking.replace(/[ \t\r\n]+$/, "");
56
+ gen.text = gen.text.replace(/[ \t\r\n]+$/, "");
57
+ gen.closed = true;
58
+ return gen;
59
+ }
60
+
61
+ // The current gen, or the one that +iteration+ names (opening one when
62
+ // needed). Returns { gen, opened, closed }.
63
+ function genFor(turn, iteration) {
64
+ if (iteration != null) {
65
+ const named = [...turn.gens].reverse().find((g) => g.iteration === iteration);
66
+ if (named) return { gen: named, opened: false, closed: null };
67
+ }
68
+ const current = currentGen(turn);
69
+ if (current && iteration != null && current.iteration == null) {
70
+ current.iteration = iteration;
71
+ return { gen: current, opened: false, closed: null };
72
+ }
73
+ if (current && iteration != null && current.iteration !== iteration) return openGen(turn, iteration);
74
+ if (current) return { gen: current, opened: false, closed: null };
75
+ return openGen(turn, iteration);
76
+ }
77
+
78
+ export function turnStarted(turn) {
79
+ return openGen(turn, null);
80
+ }
81
+
82
+ export function generationStarted(turn, iteration = null) {
83
+ const current = currentGen(turn);
84
+ if (current && isEmpty(current)) {
85
+ current.iteration = iteration ?? current.iteration;
86
+ return { gen: current, opened: false, closed: null };
87
+ }
88
+ return openGen(turn, iteration);
89
+ }
90
+
91
+ export function chunk(turn, { text = "", thinking = "", iteration = null } = {}) {
92
+ let r;
93
+ if (iteration == null) {
94
+ const current = currentGen(turn);
95
+ r = current && (current.textDone || current.tools.length) ? openGen(turn, null) : genFor(turn, null);
96
+ } else {
97
+ r = genFor(turn, iteration);
98
+ }
99
+ const { gen } = r;
100
+ // Leading whitespace (the "\n" after <think>, or after a closed
101
+ // think/tool_call block) would render as an empty first line; trim it at
102
+ // the start of a gen's thinking and text only.
103
+ if (thinking) gen.thinking += leadTrimmed(gen.thinking, thinking);
104
+ if (text) gen.text += leadTrimmed(gen.text, text);
105
+ return r;
106
+ }
107
+
108
+ export function generationCompleted(turn) {
109
+ const gen = currentGen(turn);
110
+ if (gen) gen.textDone = true;
111
+ return gen ? { gen, opened: false, closed: null } : null;
112
+ }
113
+
114
+ function toolEvent(turn, event, add) {
115
+ const r = genFor(turn, event.iteration ?? null);
116
+ const row = add(r.gen.activity, event);
117
+ return { ...r, row };
118
+ }
119
+
120
+ export function toolStarted(turn, event) {
121
+ return toolEvent(turn, event, (model, e) => addStarted(model, e).row);
122
+ }
123
+
124
+ export function toolCompleted(turn, event) {
125
+ return toolEvent(turn, event, addCompleted);
126
+ }
127
+
128
+ // A hook's line to the user, in the current step; null with no running
129
+ // turn (the page shows it as a bubble then). Not a tool row: the counts
130
+ // and labels leave it out.
131
+ export function notice(turn, { hook, text, level } = {}) {
132
+ const gen = turn && !turn.ended ? currentGen(turn) : null;
133
+ if (!gen) return null;
134
+ gen.notices.push({ hook, text, level });
135
+ return { gen };
136
+ }
137
+
138
+ // The turn is over (completed, canceled, failed, the worker gone).
139
+ export function turnEnded(turn) {
140
+ const closed = closeCurrent(turn);
141
+ turn.ended = true;
142
+ turn.plainAnswer = turn.gens.length === 1 && !turn.gens[0].thinking && turn.gens[0].tools.length === 0;
143
+ return { gen: null, opened: false, closed };
144
+ }
145
+
146
+ // The turn's answer is +gen+'s text, moved out of the block (turn_view.js
147
+ // promote): a gen left with no thinking and no tool rows is no step then,
148
+ // as it is no row, and as a reload counts it (turnGroups).
149
+ export function takeAnswer(turn, gen) {
150
+ if (gen) gen.answer = true;
151
+ return turn;
152
+ }
153
+
154
+ function isStep(gen) {
155
+ return !gen.answer || !!gen.thinking || gen.tools.length > 0;
156
+ }
157
+
158
+ // One stream event into the model; null for the types it ignores.
159
+ export function applyEvent(turn, event) {
160
+ switch (event?.type) {
161
+ case "turn_started": return turnStarted(turn);
162
+ case "generation_started": return generationStarted(turn, event.iteration ?? null);
163
+ case "generation_chunk": return chunk(turn, { text: event.text, thinking: event.thinking, iteration: event.iteration ?? null });
164
+ case "generation_completed": return generationCompleted(turn);
165
+ case "tool_call_started": return toolStarted(turn, event);
166
+ case "tool_call_completed": return toolCompleted(turn, event);
167
+ case "turn_completed": case "turn_canceled": case "turn_failed": return turnEnded(turn);
168
+ default: return null;
169
+ }
170
+ }
171
+
172
+ function toolCallsText(n) {
173
+ return `${n} tool call${n === 1 ? "" : "s"}`;
174
+ }
175
+
176
+ // The collapsed row of a gen: its narration's first line, else the tools it
177
+ // worked with, else "thinking"; then its call count.
178
+ export function genLabel(gen) {
179
+ const line = String(gen.text || "").trim().split("\n")[0].trim();
180
+ const calls = gen.tools.length;
181
+ let head;
182
+ if (line) {
183
+ head = line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
184
+ } else if (calls) {
185
+ const names = [...new Set(gen.tools.map(toolName))];
186
+ head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
187
+ } else {
188
+ return "thinking";
189
+ }
190
+ return calls ? `${head} · ${toolCallsText(calls)}` : head;
191
+ }
192
+
193
+ // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
194
+ // while it runs, without the prefix after; the tally (tally.js) from 3 calls.
195
+ export function blockSummary(turn, { running = false } = {}) {
196
+ const rows = turn.gens.flatMap((g) => g.tools);
197
+ const steps = turn.gens.filter(isStep).length;
198
+ const fields = [`${steps} step${steps === 1 ? "" : "s"}`];
199
+ const tally = tallyText(rows, { last: false });
200
+ if (tally) fields.push(tally);
201
+ else if (rows.length) fields.push(toolCallsText(rows.length));
202
+ if (running) fields.unshift("working");
203
+ return fields.join(" · ");
204
+ }