samagotchi 0.72.0 → 0.74.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +9 -3
  5. data/docs/desktop.md +2 -1
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +4 -2
  8. data/docs/internals/background-tasks.md +4 -3
  9. data/docs/plugins.md +9 -2
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/desktop/macos.rb +5 -0
  15. data/lib/samagotchi/paths.rb +8 -6
  16. data/lib/samagotchi/session.rb +5 -0
  17. data/lib/samagotchi/session_chain.rb +24 -0
  18. data/lib/samagotchi/session_continue.rb +3 -1
  19. data/lib/samagotchi/session_manager.rb +7 -0
  20. data/lib/samagotchi/tool_output_cut.rb +2 -1
  21. data/lib/samagotchi/tools/builtins.rb +17 -4
  22. data/lib/samagotchi/tools/execute.rb +1 -15
  23. data/lib/samagotchi/tools/task_runtime.rb +75 -6
  24. data/lib/samagotchi/version.rb +1 -1
  25. data/lib/samagotchi/web/app.rb +27 -3
  26. data/lib/samagotchi/web/public/app.js +33 -10
  27. data/lib/samagotchi/web/public/bubbles.js +4 -2
  28. data/lib/samagotchi/web/public/chains.js +21 -1
  29. data/lib/samagotchi/web/public/data.js +9 -0
  30. data/lib/samagotchi/web/public/index.html +39 -2
  31. data/lib/samagotchi/web/public/notice_links.js +55 -0
  32. data/lib/samagotchi/web/public/plain_line.js +55 -0
  33. data/lib/samagotchi/web/public/question_cards.js +37 -3
  34. data/lib/samagotchi/web/public/stage_model.js +3 -33
  35. data/lib/samagotchi/web/public/stage_slots.js +35 -3
  36. data/lib/samagotchi/web/public/stage_view.js +9 -4
  37. data/lib/samagotchi/web/public/turn_html.js +3 -2
  38. data/lib/samagotchi/web/public/turn_model.js +7 -4
  39. data/lib/samagotchi/web/public/turn_view.js +3 -2
  40. metadata +3 -1
@@ -22,6 +22,7 @@ import {
22
22
  import { sessionHash } from "./route.js";
23
23
  import { clientLabel, isOwn } from "./turn_events.js";
24
24
  import { continueLine } from "./commands.js";
25
+ import { plainText } from "./plain_line.js";
25
26
 
26
27
  // @param sessionId () => the open session's id (answers go there)
27
28
  // @param clientId this tab's id in the session's events
@@ -31,6 +32,9 @@ import { continueLine } from "./commands.js";
31
32
  // @param removeHintIfEmpty () drops the history's placeholder hint
32
33
  // @param askingText () => the running turn's asking reply (turn_model.js
33
34
  // askingText via the page's view), kept by a question or own approval card
35
+ // @param renderMarkdown (text) => Promise<html|null>: the kept reply's
36
+ // markdown (the page's POST /api/render; null keeps the raw text)
37
+ // @param linkRefs (el) the page's path:line ref pass on it
34
38
  // @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
35
39
  // @param doc the page's document
36
40
  // @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
@@ -38,6 +42,7 @@ import { continueLine } from "./commands.js";
38
42
  // renderContinue(offer), resolveContinue(data), forgetContinue()}
39
43
  export function createQuestionCards({
40
44
  sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
45
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
41
46
  api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
42
47
  }) {
43
48
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
@@ -400,16 +405,18 @@ export function createQuestionCards({
400
405
  // The reply that asked, kept with the card: what made the question
401
406
  // answerable stays with it in the history. Muted under the "Q → A" line
402
407
  // in two lines while the card is closed (a copy screen readers skip: not
403
- // the toggle's label), and whole, selectable, at the top of the body
408
+ // the toggle's label; plain text, plain_line.js: no "##" or "**" in a
409
+ // clamped line), and whole, selectable, at the top of the body
404
410
  // when it is open (a drag in the summary would toggle the card).
405
411
  const asked = saidOf.get(questionEl);
412
+ let full = null;
406
413
  if (summary && asked) {
407
414
  const line = doc.createElement("div");
408
415
  line.className = "question-said";
409
416
  line.setAttribute("aria-hidden", "true");
410
- line.textContent = asked;
417
+ line.textContent = plainText(asked);
411
418
  summary.appendChild(line);
412
- const full = doc.createElement("div");
419
+ full = doc.createElement("div");
413
420
  full.className = "question-said-full";
414
421
  full.textContent = asked;
415
422
  questionEl.insertBefore(full, summary.nextSibling);
@@ -421,9 +428,36 @@ export function createQuestionCards({
421
428
  const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
422
429
  if (summary && !keepNote) note.classList.add("hidden");
423
430
  if (!questionEl.dataset.toggled) questionEl.open = false;
431
+ // After the collapse: a card left open by hand renders at once.
432
+ if (full) renderSaidOnOpen(questionEl, full, asked);
424
433
  pending = null;
425
434
  }
426
435
 
436
+ // The body's copy as markdown, from the card's first open on (a reload
437
+ // draws every card in the history: rendering them all would ask once per
438
+ // card). Raw until then, and when the render answers null (then the next
439
+ // open asks again).
440
+ function renderSaidOnOpen(card, full, asked) {
441
+ let state = "raw";
442
+ const draw = () => {
443
+ if (!card.open || state !== "raw") return;
444
+ state = "asking";
445
+ Promise.resolve()
446
+ .then(() => renderMarkdown(asked))
447
+ .catch(() => null)
448
+ .then((html) => {
449
+ if (!html) { state = "raw"; return; }
450
+ state = "done";
451
+ // Sanitised on the server (POST /api/render).
452
+ full.innerHTML = html;
453
+ full.classList.add("markdown");
454
+ linkRefs(full);
455
+ });
456
+ };
457
+ card.addEventListener("toggle", draw);
458
+ draw();
459
+ }
460
+
427
461
  function markQuestionSelection(sel) {
428
462
  if (!questionEl || !sel.length) return;
429
463
  questionEl.querySelectorAll(".question-choice").forEach((button) => {
@@ -7,6 +7,7 @@ import { splitSentences } from "./sentences.js";
7
7
  import { steerSender } from "./format.js";
8
8
  import { hookNoticeLabel } from "./turn_events.js";
9
9
  import { askingText } from "./turn_model.js";
10
+ import { plainLine } from "./plain_line.js";
10
11
 
11
12
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
12
13
  const EDITS = new Set(["edit", "write", "memory_write"]);
@@ -19,37 +20,6 @@ const QUIET_MS = 1500;
19
20
  // A touch, wheel or scroll counts as use this long (phones have no hover).
20
21
  const TOUCH_MS = 4000;
21
22
 
22
- // One headline sentence as plain text: the markdown a streaming answer
23
- // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
24
- // links) goes, and a line that is only syntax (a code fence, a table's
25
- // separator row, a rule) is empty. A small pass, no renderer: a live line
26
- // only needs to read well, and an unclosed "**" mid-stream goes too.
27
- export function plainHeadline(line) {
28
- let s = String(line ?? "").trim();
29
- if (/^(```|~~~)/.test(s)) return "";
30
- if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
31
- if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
32
- s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
33
- // A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
34
- if (/^\||\|$/.test(s)) {
35
- s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
36
- }
37
- // Code spans keep their text as is; an unclosed tick just goes.
38
- const code = [];
39
- s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
40
- s = s
41
- .replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
42
- .replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
43
- .replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
44
- .replace(/`+/g, "")
45
- .replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
46
- .replace(/(\*\*|~~)/g, "")
47
- .replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
48
- .replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
49
- .replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
50
- return s.replace(/\s+/g, " ").trim();
51
- }
52
-
53
23
  // The step's newest narration sentence, as plain text: the end of a
54
24
  // finished text is a boundary (its last line may have no period), a live
55
25
  // one's not yet. A sentence that is only markdown syntax is skipped, and so
@@ -61,9 +31,9 @@ export function headlineOf(text, done) {
61
31
  const plain = [];
62
32
  for (const sentence of sentences) {
63
33
  if (/^(```|~~~)/.test(sentence)) fenced = !fenced;
64
- else if (!fenced && plainHeadline(sentence)) plain.push(plainHeadline(sentence));
34
+ else if (!fenced && plainLine(sentence)) plain.push(plainLine(sentence));
65
35
  }
66
- const fragment = fenced ? "" : plainHeadline(rest);
36
+ const fragment = fenced ? "" : plainLine(rest);
67
37
  if (done && fragment) return fragment;
68
38
  if (plain.length) return plain[plain.length - 1];
69
39
  return fragment.length > 160 ? fragment : "";
@@ -19,10 +19,19 @@ export function node(tag, cls, parent = null) {
19
19
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
20
20
  // on a running task_wait; +gens()+ the running turn's gens (the button's
21
21
  // command, read at its click); +onChange+ redraws (the stage's
22
- // scheduleRefresh).
23
- export function createStageSlots({ headline, asking, now, trail, ticks, taskStop = null, gens, onChange }) {
22
+ // scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
23
+ // (Promise<string|null>; null keeps the raw text: markdown off, a failed
24
+ // read); +linkRefs(el)+: the page's path:line ref pass on the slot.
25
+ export function createStageSlots({
26
+ headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
27
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
28
+ }) {
24
29
  // What each slot shows, so an unchanged slot is left alone.
25
30
  const shown = { headline: null, now: null, trail: null, asking: null };
31
+ // The last asking text sent to render and its answer (in flight or
32
+ // done, HTML only): the same question drawn again (after a reset, a
33
+ // blank between) asks no second time.
34
+ let rendering = { text: null, html: null };
26
35
 
27
36
  function drawHeadline({ text, thinking }) {
28
37
  const key = `${thinking ? "t" : "n"}|${text}`;
@@ -42,13 +51,35 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
42
51
 
43
52
  // Patched only when the text changes (a late chunk to the closed gen
44
53
  // rewrites the same text): a selection in it survives. A new text reads
45
- // from its top.
54
+ // from its top: drawn raw at once, then swapped for its markdown when
55
+ // that comes back while the slot still shows the same text (the text is
56
+ // final: it shows only once the call that asks runs).
46
57
  function drawAsking(text) {
47
58
  asking.hidden = !text;
48
59
  if (shown.asking === text) return;
49
60
  shown.asking = text;
61
+ asking.classList.remove("markdown");
50
62
  asking.textContent = text;
51
63
  asking.scrollTop = 0;
64
+ if (!text) return;
65
+ linkRefs(asking);
66
+ if (rendering.text !== text) {
67
+ let html;
68
+ try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
69
+ rendering = { text, html };
70
+ }
71
+ const pending = rendering;
72
+ pending.html.then((html) => {
73
+ // A null isn't kept: the next draw of this text asks again.
74
+ if (!html && rendering === pending) rendering = { text: null, html: null };
75
+ // Swapped once: a blank-then-same draw while in flight waits twice.
76
+ if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
77
+ // Sanitised on the server (POST /api/render), as an answer's HTML is.
78
+ asking.innerHTML = html;
79
+ asking.classList.add("markdown");
80
+ asking.scrollTop = 0;
81
+ linkRefs(asking);
82
+ });
52
83
  }
53
84
 
54
85
  function drawNow(tool, quiet) {
@@ -123,6 +154,7 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
123
154
  function reset() {
124
155
  for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
125
156
  asking.hidden = true;
157
+ asking.classList.remove("markdown");
126
158
  shown.headline = shown.now = shown.trail = shown.asking = null;
127
159
  }
128
160
 
@@ -48,8 +48,12 @@ function readCollapsed() {
48
48
  // the history only for a reader at its end). +dockEl+, +centerEl+ and
49
49
  // +composerEl+ size the stage's cap.
50
50
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
51
- // on a running task_wait.
52
- export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
51
+ // on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
52
+ // slot's markdown read and ref pass (stage_slots.js).
53
+ export function createStageView({
54
+ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null,
55
+ renderMarkdown, linkRefs,
56
+ }) {
53
57
  // The dock becomes a clear column: the stage's card, then the composer's
54
58
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
55
59
  document.body.classList.add("stage-view");
@@ -82,7 +86,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
82
86
  headline.setAttribute("role", "button");
83
87
  headline.title = "Show this step's reasoning";
84
88
  // While a question or approval waits: the whole reply that asked it, in
85
- // the headline's place (the card says what to answer, this says why).
89
+ // the headline's place (the card says what to answer, this says why),
90
+ // as markdown when web.markdown is on.
86
91
  const asking = node("div", "ts-asking", live);
87
92
  asking.hidden = true;
88
93
  const now = node("div", "ts-now", live);
@@ -111,7 +116,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
111
116
  // "queued"), each to run as a turn of its own; they hand off last.
112
117
  const queuedSlot = node("div", "ts-queued", stage);
113
118
  const slotsView = createStageSlots({
114
- headline, asking, now, trail, ticks, taskStop,
119
+ headline, asking, now, trail, ticks, taskStop, renderMarkdown, linkRefs,
115
120
  gens: () => view.current().turn?.gens,
116
121
  onChange: () => scheduleRefresh(),
117
122
  });
@@ -83,8 +83,9 @@ function llmEditTemplate(row) {
83
83
  // A gate-corrected call's `ran as:` line shows on hover only.
84
84
  const INLINE_CHARS = 300;
85
85
  // The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
86
- // signal-killed command's has no number ("exit: ").
87
- const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
86
+ // signal-killed command's has no number ("exit: "). spec/fixtures/exit_lines.json holds the cases both
87
+ // must answer alike (exported for turn_html.test.js).
88
+ export const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
88
89
  // A bare success exit line; group 1 is the hint line after it, if any.
89
90
  const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
90
91
  const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
@@ -26,6 +26,7 @@
26
26
  import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
27
27
  import { tallyText } from "./tally.js";
28
28
  import { clip, leadTrimmed } from "./format.js";
29
+ import { plainLine } from "./plain_line.js";
29
30
 
30
31
  const LABEL_MAX = 80;
31
32
  const LABEL_TOOLS = 3;
@@ -210,9 +211,11 @@ export function failedCount(rows) {
210
211
  return rows.filter((r) => r.status === "error").length;
211
212
  }
212
213
 
213
- // The collapsed row of a gen: its narration's first line, else its first
214
- // call's title ("edit lib/a.rb"), else the tools it worked with, else
215
- // "thinking"; then its call count and how many failed.
214
+ // The collapsed row of a gen: its narration's first line as plain text
215
+ // (plain_line.js: "## Plan" reads "Plan"; a line that is only syntax, a rule
216
+ // or a fence, gives way to the next), else its first call's title ("edit
217
+ // lib/a.rb"), else the tools it worked with, else "thinking"; then its call
218
+ // count and how many failed.
216
219
  export function genLabel(gen) {
217
220
  const { head, calls } = genLabelParts(gen);
218
221
  return calls ? `${head} · ${calls}` : head;
@@ -223,7 +226,7 @@ export function genLabel(gen) {
223
226
  // starts with (its narration, its first call's title), so an open step's
224
227
  // summary can say only its calls (turn_view.js labelHtml).
225
228
  export function genLabelParts(gen) {
226
- const line = String(gen.text || "").trim().split("\n")[0].trim();
229
+ const line = String(gen.text || "").split("\n").map(plainLine).find(Boolean) || "";
227
230
  const calls = gen.tools.length ? toolCallsText(gen.tools) : "";
228
231
  const first = gen.tools[0];
229
232
  if (line) return { head: cut(line), calls, repeats: true };
@@ -37,6 +37,7 @@ import { createSentenceFeed } from "./sentences.js";
37
37
  import { createTicker } from "./thinking_ticker.js";
38
38
  import * as model from "./turn_model.js";
39
39
  import { createHold } from "./hold.js";
40
+ import { linkedLine } from "./notice_links.js";
40
41
  import { taskCommand, taskStopButton } from "./task_stop.js";
41
42
 
42
43
  // ---- The templates: one step, its thinking, its label ----
@@ -99,10 +100,10 @@ export function stepTemplate(gen, st = {}) {
99
100
 
100
101
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
101
102
  // chi's own (an empty-answer retry), shown as is; data.cls a class of its
102
- // own and data.title its hover (a ✂ row's).
103
+ // own and data.title its hover (a ✂ row's). Its http(s) URLs are links.
103
104
  function noticeTemplate(data) {
104
105
  const cls = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
105
- return html`<div class=${cls} title=${data.title || nothing}>${data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`}</div>`;
106
+ return html`<div class=${cls} title=${data.title || nothing}>${linkedLine(data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`)}</div>`;
106
107
  }
107
108
 
108
109
  // +template+ rendered as detached nodes (a fragment).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.72.0
4
+ version: 0.74.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -614,8 +614,10 @@ files:
614
614
  - lib/samagotchi/web/public/llm_context_chip.js
615
615
  - lib/samagotchi/web/public/model_pick.js
616
616
  - lib/samagotchi/web/public/model_picker.js
617
+ - lib/samagotchi/web/public/notice_links.js
617
618
  - lib/samagotchi/web/public/notifications.js
618
619
  - lib/samagotchi/web/public/notify.js
620
+ - lib/samagotchi/web/public/plain_line.js
619
621
  - lib/samagotchi/web/public/prompt_history.js
620
622
  - lib/samagotchi/web/public/question_card.js
621
623
  - lib/samagotchi/web/public/question_cards.js