samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -1,376 +0,0 @@
1
- // The chat view of a running turn: one streaming answer bubble per
2
- // generation, one thinking block per turn (above the first bubble) and one
3
- // activity panel per turn (at the first tool call). This module owns that
4
- // per-turn DOM; app.js's stream handlers call it and keep the rest (prompt
5
- // bubbles, cards, the timing line, the session state). turn_view.js offers
6
- // the same interface with a different placement; app.js picks one at boot.
7
- //
8
- // deps: historyEl, appendToHistory(el) (above the live timing line),
9
- // isNearBottom(), sawText(normalizedText) (the turn output dedupe),
10
- // sessionId() (image thumbs of a tool row).
11
- import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
12
- import { copySourceAttr } from "./copy.js";
13
- import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
14
- import { activityDiffHtml } from "./diff_view.js";
15
- import { thumbsHtml } from "./images.js";
16
- import { shouldFollowScroll } from "./scroll.js";
17
- import { tallyText } from "./tally.js";
18
- import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
19
-
20
- // A row's status (live, or a saved tool record's on reload): running,
21
- // error, stopped (a task_wait the user's Stop ended), else ok ("done").
22
- export function activityStatusClass(status) {
23
- if (status === "running") return "running";
24
- if (status === "error") return "error";
25
- if (status === "stopped") return "stopped";
26
- return "ok";
27
- }
28
-
29
- export function activityStatusLabel(status) {
30
- if (status === "running") return "running";
31
- if (status === "error") return "error";
32
- if (status === "stopped") return "stopped";
33
- return "done";
34
- }
35
-
36
- export function createChatView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
37
- let streamingBubble = null;
38
- let streamingBuffer = "";
39
- let rafPending = false;
40
- let activityModel = null;
41
- let activityEl = null;
42
- let activityElBody = null;
43
- const activityRowEls = new Map();
44
- // One live "thinking" block per turn (Claude-like); streams :generation_chunk
45
- // `thinking` deltas and auto-collapses on turn_completed.
46
- let thinkingEl = null;
47
- let thinkingBodyEl = null;
48
- let thinkingBuffer = "";
49
-
50
- function removeHint() {
51
- const hint = historyEl.querySelector(".hint");
52
- if (hint) hint.remove();
53
- }
54
-
55
- function ensureStreamingBubble() {
56
- if (streamingBubble) return streamingBubble;
57
- removeHint();
58
- streamingBubble = document.createElement("div");
59
- streamingBubble.className = "bubble output streaming";
60
- streamingBubble.textContent = "";
61
- appendToHistory(streamingBubble);
62
- return streamingBubble;
63
- }
64
-
65
- function flush() {
66
- rafPending = false;
67
- // Runs every animation frame while streaming — must not yank a user who
68
- // scrolled up to read; only follow when they were already near the bottom.
69
- const follow = isNearBottom();
70
- // An empty buffer keeps the "…" placeholder.
71
- if (streamingBubble && streamingBuffer) {
72
- streamingBubble.textContent = streamingBuffer;
73
- }
74
- if (thinkingBodyEl) {
75
- // The capped thinking box follows its own tail the same way, unless the
76
- // user scrolled up inside it.
77
- const followThinking = shouldFollowScroll(thinkingBodyEl, 24);
78
- thinkingBodyEl.textContent = thinkingBuffer;
79
- if (followThinking) thinkingBodyEl.scrollTop = thinkingBodyEl.scrollHeight;
80
- }
81
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
82
- }
83
-
84
- function scheduleFlush() {
85
- if (rafPending) return;
86
- rafPending = true;
87
- requestAnimationFrame(flush);
88
- }
89
-
90
- function appendToken(content) {
91
- if (!content) return;
92
- // Leading whitespace right after a closed think/tool_call block (or at the
93
- // start of the stream) would otherwise render as an empty first line in
94
- // the pre-wrap bubble. Trim it only at the start of a bubble so mid-bubble
95
- // paragraph spacing is preserved.
96
- if (streamingBuffer === "") content = content.replace(/^[ \t\r\n]+/, "");
97
- if (!content) return;
98
- streamingBuffer += content;
99
- ensureStreamingBubble();
100
- scheduleFlush();
101
- }
102
-
103
- // ── Per-turn live thinking block ─────────────────────────────────────────
104
- // Routed from :generation_chunk `thinking`. One <details> per turn; accumulates
105
- // across the turn's generations and collapses on turn completion.
106
-
107
- function ensureThinkingEl() {
108
- if (thinkingEl) return thinkingEl;
109
- removeHint();
110
- const details = document.createElement("details");
111
- details.className = "bubble thinking";
112
- details.open = true;
113
- const summary = document.createElement("summary");
114
- summary.textContent = "thinking";
115
- details.appendChild(summary);
116
- const body = document.createElement("div");
117
- body.className = "thinking-body";
118
- details.appendChild(body);
119
- thinkingEl = details;
120
- thinkingBodyEl = body;
121
- // Prefer Claude-like ordering: thinking above the streaming answer bubble.
122
- if (streamingBubble && streamingBubble.parentNode === historyEl) {
123
- historyEl.insertBefore(details, streamingBubble);
124
- } else {
125
- appendToHistory(details);
126
- }
127
- return details;
128
- }
129
-
130
- function appendThinking(content) {
131
- // The box starts at the first visible character, not the "\n" after <think>.
132
- const piece = leadTrimmed(thinkingBuffer, content);
133
- if (!piece) return;
134
- thinkingBuffer += piece;
135
- ensureThinkingEl();
136
- scheduleFlush();
137
- }
138
-
139
- function finalizeThinkingEl() {
140
- if (thinkingBodyEl) thinkingBodyEl.textContent = thinkingBuffer.replace(/[ \t\r\n]+$/, "");
141
- if (thinkingEl) thinkingEl.open = false;
142
- thinkingBuffer = "";
143
- }
144
-
145
- function resetThinkingState() {
146
- thinkingEl = null;
147
- thinkingBodyEl = null;
148
- thinkingBuffer = "";
149
- }
150
-
151
- function finalizeStreaming() {
152
- if (streamingBubble) {
153
- const k = normalize(streamingBuffer);
154
- // The text lands in the bubble on the next animation frame; a generation
155
- // can end before one runs (a burst of frames, a hidden tab), so write it
156
- // here too or the bubble keeps its "…" placeholder.
157
- if (k) {
158
- streamingBubble.textContent = streamingBuffer;
159
- sawText(k);
160
- }
161
- else streamingBubble.remove(); // drop text-less bubbles (thinking/tool-only generations, "…" placeholder)
162
- streamingBubble.classList.remove("streaming");
163
- streamingBubble = null;
164
- }
165
- streamingBuffer = "";
166
- rafPending = false;
167
- }
168
-
169
- // ── Per-turn tool/memory activity panel ──────────────────────────────────
170
- // One collapsible <details> per turn; each :tool_call_started/completed upserts
171
- // a row (keyed by iteration:call_index) that transitions running -> ok/error.
172
- // Memory reads/writes flow through the same tool_call events, so they show here too.
173
-
174
- function ensureActivityEl() {
175
- if (activityEl) return activityEl;
176
- removeHint();
177
- const details = document.createElement("details");
178
- details.className = "bubble activity";
179
- details.open = true;
180
- const summary = document.createElement("summary");
181
- summary.textContent = "activity";
182
- details.appendChild(summary);
183
- const body = document.createElement("div");
184
- body.className = "activity-body";
185
- details.appendChild(body);
186
- activityEl = details;
187
- activityElBody = body;
188
- appendToHistory(details);
189
- return details;
190
- }
191
-
192
- function renderActivityRow(row) {
193
- const el = document.createElement("div");
194
- el.className = "activity-row";
195
- el.dataset.key = row.key;
196
- const statusEl = document.createElement("span");
197
- statusEl.className = "activity-status";
198
- const toolEl = document.createElement("span");
199
- toolEl.className = "activity-tool";
200
- toolEl.textContent = toolName(row);
201
- const paramsEl = document.createElement("span");
202
- paramsEl.className = "activity-params";
203
- const outEl = document.createElement("div");
204
- outEl.className = "activity-output";
205
- outEl.style.display = "none";
206
- el.appendChild(statusEl);
207
- el.appendChild(toolEl);
208
- el.appendChild(paramsEl);
209
- el.appendChild(outEl);
210
- activityRowEls.set(row.key, el);
211
- const follow = isNearBottom();
212
- (activityElBody || ensureActivityEl().querySelector(".activity-body")).appendChild(el);
213
- updateActivityRowEl(row);
214
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
215
- }
216
-
217
- function updateActivityRowEl(row) {
218
- const el = activityRowEls.get(row.key);
219
- if (!el) return;
220
- fillActivityRow(el, row, sessionId());
221
- }
222
-
223
- function updateActivitySummary() {
224
- if (!activityEl) return;
225
- const summary = activityEl.querySelector("summary");
226
- if (!summary) return;
227
- // From the 3rd call: "activity · 12 tool calls (2 failed) · execute ×7 · …" (the rows show the last call).
228
- const tally = tallyText(activityModel?.rows, { last: false });
229
- summary.textContent = tally ? `activity · ${tally}` : `activity (${activityCount(activityModel)})`;
230
- }
231
-
232
- function finalizeActivityEl() {
233
- if (activityModel) finalizeActivity(activityModel);
234
- if (activityEl) activityEl.open = false;
235
- }
236
-
237
- function resetActivityState() {
238
- activityModel = null;
239
- activityEl = null;
240
- activityElBody = null;
241
- activityRowEls.clear();
242
- }
243
-
244
- function toolEvent(data, add) {
245
- const model = activityModel || (activityModel = newActivity());
246
- ensureActivityEl();
247
- const row = add(model, data);
248
- if (!activityRowEls.has(row.key)) renderActivityRow(row);
249
- else updateActivityRowEl(row);
250
- updateActivitySummary();
251
- }
252
-
253
- return {
254
- turnStarted(data) {
255
- resetActivityState();
256
- resetThinkingState();
257
- streamingBuffer = "";
258
- ensureStreamingBubble();
259
- if (data.prompt) streamingBubble.textContent = "…";
260
- },
261
- generationStarted() {
262
- // A generation left open (a join mid-way) ends where the next begins.
263
- finalizeStreaming();
264
- ensureStreamingBubble();
265
- },
266
- chunk({ text, thinking }) {
267
- if (text) appendToken(text);
268
- if (thinking) appendThinking(thinking);
269
- },
270
- generationCompleted() {
271
- finalizeStreaming();
272
- },
273
- toolStarted(data) {
274
- toolEvent(data, (model, event) => addStarted(model, event).row);
275
- },
276
- toolCompleted(data) {
277
- toolEvent(data, addCompleted);
278
- },
279
- // Everything a turn's end (completed, canceled, failed) closes.
280
- turnEnded() {
281
- finalizeStreaming();
282
- finalizeActivityEl();
283
- finalizeThinkingEl();
284
- },
285
- // A hook's notice stays the page's bubble in this view.
286
- notice() {
287
- return false;
288
- },
289
- // So is a plugin's steer.
290
- steer() {
291
- return false;
292
- },
293
- // So is a card.
294
- card() {
295
- return false;
296
- },
297
- flush,
298
- // The element is rewritten every frame while the turn runs (annotate
299
- // skips it).
300
- containsLive(el) {
301
- return !!thinkingEl?.contains(el);
302
- },
303
- reset() {
304
- streamingBubble = null;
305
- streamingBuffer = "";
306
- rafPending = false;
307
- resetActivityState();
308
- resetThinkingState();
309
- },
310
- };
311
- }
312
-
313
- // One tool row's status, name, params, output and image thumbs from its
314
- // activity row (activity.js). Shared with the turn view.
315
- // A reloaded history in the chat view (renderHistory's object path): one
316
- // bubble per message; each turn's timing line under its last bubble, then
317
- // its cancel line, where the live view leaves them.
318
- // +thumbs(images)+ draws a prompt's image thumbs.
319
- export function chatHistoryHtml(items, timing, { thumbs }) {
320
- const timedTurns = timedTurnIndexes(items);
321
- return items
322
- .map((m, i) => {
323
- if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
324
- if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
325
- const turnIndex = timedTurns[i];
326
- const cls = m.role === "user" ? "user" : "output";
327
- const { body, renderedMarkdown } = messageBodyHtml(m);
328
- const markdown = renderedMarkdown ? " markdown" : "";
329
- const record = turnIndex === null ? null : turnRecordAt(timing, turnIndex);
330
- const timingHtml = record && formatDuration(record.duration_ms)
331
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
332
- : "";
333
- // A canceled turn ends with its cancel line, as the live view left it.
334
- const cancelHtml = record ? cancelLineHtml(record, escapeHtml) : "";
335
- // A user bubble is a flex row (label, text, badge): its text sits in
336
- // one block so quotes stack instead of lining up side by side.
337
- const inner = m.role === "user" ? `<div class="user-message">${body}${thumbs(m.images)}</div>` : body;
338
- // The timing line goes after the bubble, not in it: under the answer,
339
- // or under the prompt of a turn canceled before any answer.
340
- return `<div class="bubble ${cls}${markdown}"${copySourceAttr(m, renderedMarkdown)}>${inner}</div>${timingHtml}${cancelHtml}`;
341
- })
342
- .join("");
343
- }
344
-
345
- export function fillActivityRow(el, row, sessionId) {
346
- const statusEl = el.querySelector(".activity-status");
347
- statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
348
- statusEl.textContent = activityStatusLabel(row.status);
349
- const toolEl = el.querySelector(".activity-tool");
350
- if (toolEl) toolEl.textContent = toolName(row);
351
- const paramsEl = el.querySelector(".activity-params");
352
- if (paramsEl) {
353
- // The title in place of the params; the full params line on hover.
354
- paramsEl.textContent = row.title || row.params || "";
355
- paramsEl.title = row.title && row.params ? row.params : "";
356
- }
357
- const outEl = el.querySelector(".activity-output");
358
- if (outEl) {
359
- if (row.status === "running") {
360
- outEl.style.display = "none";
361
- outEl.textContent = "";
362
- outEl.title = "";
363
- } else {
364
- // dispatch prefixes output with "[tool]"; strip that noise for display.
365
- const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
366
- outEl.title = full;
367
- outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
368
- outEl.style.display = full ? "" : "none";
369
- }
370
- }
371
- // The images a tool returned (the chat view's reloaded history has no tool
372
- // rows; the turn view's has, turn_view.js reloadRowHtml).
373
- if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
374
- // An edit/write's change, collapsed: "diff +3 −1".
375
- if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
376
- }