samagotchi 0.99.0 → 0.100.1

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.
@@ -3,6 +3,7 @@
3
3
  require_relative "../cancellation_controller"
4
4
  require_relative "../plugin/context"
5
5
  require_relative "../session_metrics/cost"
6
+ require_relative "../session_metrics/usage"
6
7
  require_relative "recorder"
7
8
 
8
9
  module Samagotchi
@@ -49,7 +50,8 @@ module Samagotchi
49
50
  use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
50
51
  load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
51
52
  cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
52
- cost: -> { cost(recorder[:cost]) }
53
+ cost: -> { cost(recorder[:cost]) },
54
+ usage: -> { usage(recorder[:usage]) }
53
55
  )
54
56
  end
55
57
 
@@ -75,6 +77,33 @@ module Samagotchi
75
77
  else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
76
78
  end
77
79
  end
80
+
81
+ # The parts a test may give bundle.set(usage: {...}).
82
+ USAGE_PARTS = %i[turns tool_calls tool_calls_by_tool steps active_ms tool_ms_by_tool].freeze
83
+
84
+ # The SessionMetrics::Usage ctx.usage returns for the recorder's
85
+ # +usage+ value (Recorder::DEFAULTS): zeros for the parts not given;
86
+ # tool_calls defaults to the sum of tool_calls_by_tool, active_ms to
87
+ # the sum of tool_ms_by_tool.
88
+ # @return [SessionMetrics::Usage]
89
+ def usage(value)
90
+ value = value.call while value.respond_to?(:call)
91
+ case value
92
+ when nil then SessionMetrics::Usage.zero
93
+ when SessionMetrics::Usage then value
94
+ when Hash
95
+ parts = value.transform_keys(&:to_sym)
96
+ unknown = parts.keys - USAGE_PARTS
97
+ raise ArgumentError, "unknown usage parts: #{unknown.join(", ")}" unless unknown.empty?
98
+
99
+ by_tool = parts.fetch(:tool_calls_by_tool, {}).transform_keys(&:to_s)
100
+ ms_by_tool = parts.fetch(:tool_ms_by_tool, {}).transform_keys(&:to_s)
101
+ SessionMetrics::Usage.new(turns: parts.fetch(:turns, 0), tool_calls: parts.fetch(:tool_calls, by_tool.values.sum),
102
+ tool_calls_by_tool: by_tool, steps: parts.fetch(:steps, 0),
103
+ active_ms: parts.fetch(:active_ms, ms_by_tool.values.sum), tool_ms_by_tool: ms_by_tool)
104
+ else raise ArgumentError, "usage: a Hash or a callable, not #{value.inspect}"
105
+ end
106
+ end
78
107
  end
79
108
 
80
109
  # The hook runtime (Hooks::Runtime: event[:notify] and the other
@@ -37,7 +37,11 @@ module Samagotchi
37
37
  prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true, load_tools_staged: nil,
38
38
  # ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
39
39
  # (reported:, estimated:, side:, known:) or a callable giving one.
40
- cost: nil
40
+ cost: nil,
41
+ # ctx.usage: nil (zero), a Hash of the parts (turns:, tool_calls:,
42
+ # tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool:) or a
43
+ # callable giving one.
44
+ usage: nil
41
45
  }.freeze
42
46
 
43
47
  attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.99.0"
4
+ VERSION = "0.100.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -38,7 +38,7 @@ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
38
38
  import { createCardsPlace } from "./cards_place.js";
39
39
  import { createStageView } from "./stage_view.js";
40
40
  import { createMarkdownPlace } from "./markdown_place.js";
41
- import { openerText, openTitle } from "./chains.js";
41
+ import { openerFill, openTitle, restoredComposer } from "./chains.js";
42
42
  import { createTaskStopper } from "./task_stop.js";
43
43
  import { appendQuote } from "./annotations.js";
44
44
  import { parsePresets } from "./annotate_presets.js";
@@ -67,7 +67,7 @@ import {
67
67
  appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
68
  } from "./timing.js";
69
69
  import { createLiveTurn } from "./live_turn.js";
70
- import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
70
+ import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
71
71
  import { installRefClicks } from "./refs.js";
72
72
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
73
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -174,6 +174,15 @@ const infoCancelBtn = $("#infoCancelBtn");
174
174
  const composeHintEl = $("#composeHint");
175
175
 
176
176
  let selected = null;
177
+ // The session the composer's text was last written in ("" the start page,
178
+ // undefined unknown): the composer is one for every session, and continue →
179
+ // keeps only a draft of its own (fillOpener).
180
+ let draftFrom;
181
+ // Record that the composer's text was written now, in session +id+ (default
182
+ // the open one). Every write of text into the composer comes through here.
183
+ function noteDraft(id) {
184
+ draftFrom = id ?? selected ?? "";
185
+ }
177
186
  let streamControl = null;
178
187
  // The session list's stream (GET /api/events); null while the page falls
179
188
  // back to fetching (a chi web without a hub).
@@ -213,6 +222,7 @@ const markdownPlace = createMarkdownPlace({
213
222
  normalize,
214
223
  pairAnswerRefs,
215
224
  setAnswerRefs,
225
+ answerRefsOf,
216
226
  });
217
227
  const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
218
228
  const turnDom = view === "stage"
@@ -500,6 +510,7 @@ const sessionActions = createSessionActions({
500
510
  toast: showToast,
501
511
  addCreatedSession,
502
512
  fillOpener,
513
+ restoreDraft,
503
514
  closeLiveStream: () => streamControl?.close(),
504
515
  workerGone,
505
516
  });
@@ -759,6 +770,7 @@ function clearNotice() {
759
770
  noticeEl.textContent = "";
760
771
  }
761
772
  promptEl.addEventListener("input", clearNotice);
773
+ promptEl.addEventListener("input", () => noteDraft());
762
774
 
763
775
  async function select(id) {
764
776
  clearNotice();
@@ -1277,6 +1289,7 @@ const streamHandlers = createStreamHandlers({
1277
1289
  // A failed turn's line back: seen, so the composer opens (this visit).
1278
1290
  openForVisit();
1279
1291
  promptEl.value = text;
1292
+ noteDraft();
1280
1293
  fitPrompt();
1281
1294
  },
1282
1295
  setChips,
@@ -1372,6 +1385,7 @@ const sendTurnControl = createSendTurn({
1372
1385
  clear: clearComposer,
1373
1386
  openForVisit: () => openForVisit(),
1374
1387
  focus: () => focusPrompt(),
1388
+ noteDraft,
1375
1389
  },
1376
1390
  historyEl,
1377
1391
  turnDom,
@@ -1525,6 +1539,7 @@ const unqueue = createUnqueue({
1525
1539
  if (promptEl.value.trim() || chips.length) return false;
1526
1540
  openForVisit();
1527
1541
  promptEl.value = text;
1542
+ noteDraft();
1528
1543
  fitPrompt();
1529
1544
  promptEl.focus();
1530
1545
  return true;
@@ -1571,7 +1586,10 @@ const commandList = createCommandList({
1571
1586
  list: $("#commandList"),
1572
1587
  prompt: promptEl,
1573
1588
  commands: () => (selected && current.owner !== "tui" ? current.commands : []),
1574
- afterPick: () => fitPrompt(),
1589
+ afterPick: () => {
1590
+ noteDraft();
1591
+ fitPrompt();
1592
+ },
1575
1593
  });
1576
1594
 
1577
1595
  // ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
@@ -1588,6 +1606,7 @@ function historyKey(e) {
1588
1606
  if (!recalled) return false;
1589
1607
  e.preventDefault();
1590
1608
  promptEl.value = recalled.value;
1609
+ noteDraft();
1591
1610
  promptEl.setSelectionRange(recalled.caret, recalled.caret);
1592
1611
  fitPrompt();
1593
1612
  return true;
@@ -1620,15 +1639,31 @@ $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1620
1639
  $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
1621
1640
 
1622
1641
  // The new link's composer: the opener (openerText: the previous link's own,
1623
- // else the constant), unless a draft is there (it stays). +created+: the
1624
- // continue's 201.
1625
- function fillOpener(created) {
1626
- if (promptEl.value.trim() || chips.length) return;
1642
+ // else the constant). A draft written in this session (or the new link, or
1643
+ // of unknown origin) stays instead; one carried over from another session or
1644
+ // the start page gives way to the opener and is returned, for the toast's
1645
+ // Restore draft (chains.js openerFill). +created+: the continue's 201;
1646
+ // +continuedId+: the session continued.
1647
+ // @return the displaced draft's text, or null
1648
+ function fillOpener(created, continuedId) {
1649
+ const { value, displaced } = openerFill({
1650
+ text: promptEl.value, hasChips: chips.length > 0, draftFrom, continuedId, linkId: created.id, created,
1651
+ });
1652
+ if (value === null) return null;
1627
1653
  openForVisit();
1628
- promptEl.value = openerText(created);
1654
+ promptEl.value = value;
1655
+ noteDraft(created.id);
1629
1656
  fitPrompt();
1630
1657
  focusPrompt();
1631
1658
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1659
+ return displaced;
1660
+ }
1661
+
1662
+ // Restore draft (the continue toast's): +draft+ back into the composer,
1663
+ // over the untouched +opener+ or an empty one, else after what is there
1664
+ // (chains.js restoredComposer); in whatever session is open now.
1665
+ function restoreDraft(draft, opener) {
1666
+ fillComposer(restoredComposer({ current: promptEl.value, opener, draft }));
1632
1667
  }
1633
1668
 
1634
1669
  const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
@@ -1642,6 +1677,7 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
1642
1677
  function fillComposer(text) {
1643
1678
  openForVisit();
1644
1679
  promptEl.value = text;
1680
+ noteDraft();
1645
1681
  fitPrompt();
1646
1682
  focusPrompt();
1647
1683
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
@@ -25,6 +25,30 @@ export function openerText(created) {
25
25
  return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
26
26
  }
27
27
 
28
+ // What continue → does with the composer as the new link opens (app.js
29
+ // fillOpener): +text+ / +hasChips+ the composer now; +draftFrom+ the session
30
+ // the text was last written in ("" the start page, undefined unknown);
31
+ // +continuedId+ the session continued, +linkId+ the new link. An empty
32
+ // composer gets the opener. A draft written in either session, or of unknown
33
+ // origin, stays (+value+ null). One carried over from another session or the
34
+ // start page gives way to the opener and comes back as +displaced+ (the
35
+ // toast's Restore draft). Its image chips stay either way: only text is
36
+ // replaced.
37
+ export function openerFill({ text, hasChips, draftFrom, continuedId, linkId, created }) {
38
+ const hasText = !!text?.trim();
39
+ if (!hasText && !hasChips) return { value: openerText(created), displaced: null };
40
+ if (draftFrom === undefined || draftFrom === continuedId || draftFrom === linkId) return { value: null, displaced: null };
41
+ return { value: openerText(created), displaced: hasText ? text : null };
42
+ }
43
+
44
+ // The composer after Restore draft: +draft+ (a displaced draft) replaces
45
+ // +current+ when that is empty or still exactly +opener+; else it goes after
46
+ // it, past a blank line. Never loses text typed since.
47
+ export function restoredComposer({ current, opener, draft }) {
48
+ if (!current.trim() || current === opener) return draft;
49
+ return `${current.replace(/\s+$/, "")}\n\n${draft}`;
50
+ }
51
+
28
52
  // Whether +text+ (a title or a first message) is the opener and nothing
29
53
  // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
30
54
  export function isOpener(text) {
@@ -345,7 +345,13 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
345
345
  --md-strong-color:var(--text-bright);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
346
346
  --md-pre-size:12.5px;--md-cell-size:13px
347
347
  }
348
- .md-compact:is(.ts-asking,.question-said-full){white-space:normal}
348
+ .gen-text.md-compact{
349
+ --md-heading-size:14.5px;--md-heading-color:var(--text);
350
+ --md-subheading-size:14px;--md-subheading-color:var(--text-2);
351
+ --md-strong-color:var(--text);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
352
+ --md-pre-size:12.5px;--md-cell-size:13px
353
+ }
354
+ .md-compact:is(.ts-asking,.question-said-full,.gen-text){white-space:normal}
349
355
  .md-compact :is(h1,h2,h3,h4,h5,h6){font-size:var(--md-heading-size);font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--md-heading-color)}
350
356
  .md-compact :is(h4,h5,h6){font-size:var(--md-subheading-size);color:var(--md-subheading-color)}
351
357
  .md-compact :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
@@ -7,11 +7,17 @@
7
7
  // The texts whose refs linkTextRefs links: the answers and the closed
8
8
  // steps' texts.
9
9
  const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
10
+ // The closed steps' texts still shown raw: a live turn's (a reload's come
11
+ // rendered, turn_view.js textTemplate).
12
+ const RAW_STEPS = ".gen:not(.live) > .gen-text:not(.markdown)";
10
13
 
11
14
  export function createMarkdownPlace({
12
15
  capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
13
- pairAnswerRefs, setAnswerRefs = () => {},
16
+ pairAnswerRefs, setAnswerRefs = () => {}, answerRefsOf = () => null,
14
17
  }) {
18
+ // The raw step texts already waiting for their step's open.
19
+ const waiting = new WeakSet();
20
+
15
21
  // A text the page already shows as its markdown HTML (POST /api/render,
16
22
  // sanitised there), or null: markdown off here (no request), or a failed
17
23
  // read (the raw text stays).
@@ -55,6 +61,7 @@ export function createMarkdownPlace({
55
61
  // turn's end and after a reload's render. A ref already made stays as it
56
62
  // is.
57
63
  function linkTextRefs() {
64
+ renderStepTexts();
58
65
  const pageRoots = roots();
59
66
  const touched = touchedFiles(pageRoots);
60
67
  for (const root of pageRoots) {
@@ -66,5 +73,44 @@ export function createMarkdownPlace({
66
73
  }
67
74
  }
68
75
 
76
+ // The closed steps' raw texts as markdown, as the answers' (markdown on
77
+ // only): each from its step's first open (a turn's end would ask once per
78
+ // step, folded or not), as a resolved question card's kept reply. The
79
+ // text stays as its source (stepText); its refs, a question's, pair again
80
+ // on the swap, then the `path:line` ones (linkRefsIn). A null render
81
+ // keeps the text, and the next open asks again.
82
+ function renderStepTexts() {
83
+ if (capabilities().display !== true) return;
84
+ for (const root of roots()) {
85
+ for (const el of root.querySelectorAll(RAW_STEPS)) {
86
+ const step = el.parentElement;
87
+ if (waiting.has(el) || !step?.addEventListener) continue;
88
+ waiting.add(el);
89
+ renderOnOpen(step, el, el.textContent);
90
+ }
91
+ }
92
+ }
93
+
94
+ function renderOnOpen(step, el, text) {
95
+ let state = "raw";
96
+ const draw = () => {
97
+ if (!step.open || state !== "raw") return;
98
+ state = "asking";
99
+ renderMarkdown(text).then((html) => {
100
+ if (!html || el.textContent !== text) { state = html ? "done" : "raw"; return; }
101
+ state = "done";
102
+ step.removeEventListener("toggle", draw);
103
+ const refs = answerRefsOf(el);
104
+ el.setAttribute("data-copy-source", text);
105
+ // Sanitised on the server (POST /api/render).
106
+ el.innerHTML = html;
107
+ el.classList.add("markdown", "md-compact");
108
+ linkRefsIn(el, refs);
109
+ });
110
+ };
111
+ step.addEventListener("toggle", draw);
112
+ draw();
113
+ }
114
+
69
115
  return { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs };
70
116
  }
@@ -19,7 +19,8 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
19
19
  // (start_page.js: pendingPick(), chosenModel(), newChoices(),
20
20
  // clearChoices()); +takeDelivery()+ the delivery line's armed choice;
21
21
  // +composer+: {el (the text box), button (Send/Start), chips(),
22
- // setChips(list), fit(), clear(), openForVisit(), focus()}; +historyEl+,
22
+ // setChips(list), fit(), clear(), openForVisit(), focus(), noteDraft(id) (the
23
+ // text was written in session id)}; +historyEl+,
23
24
  // +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
24
25
  // +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
25
26
  // +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
@@ -128,7 +129,10 @@ export function createSendTurn({
128
129
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
129
130
  // A restored line must be seen: the composer opens for this visit.
130
131
  if (restored?.refill && sentTo === session()) composer.openForVisit();
131
- promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
132
+ const before = promptEl.value;
133
+ promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
134
+ // A line back from sentTo is a draft of that session, wherever the page is now.
135
+ if (promptEl.value.trim() && promptEl.value !== before) composer.noteDraft(sentTo);
132
136
  composer.fit();
133
137
  // Only the sent chips go: images added since stay (they go up at their own send).
134
138
  const kept = chipsAfterSend(composer.chips(), sending);
@@ -1,4 +1,4 @@
1
- import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
1
+ import { continueRefusal, continuedText, openChildrenTemplate, openerText } from "./chains.js";
2
2
  import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
3
3
  import { sessionHash } from "./route.js";
4
4
  import { inTree } from "./sessions_list.js";
@@ -14,12 +14,14 @@ import { inTree } from "./sessions_list.js";
14
14
  // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
15
15
  // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
16
16
  // +toast(text, action, options)+, +addCreatedSession(created)+,
17
- // +fillOpener(created)+ (the new link's composer), +closeLiveStream()+ (the live
17
+ // +fillOpener(created, continuedId)+ (the new link's composer; returns a draft
18
+ // from another session it displaced, or null), +restoreDraft(draft, opener)+
19
+ // (that draft back, never over newer text), +closeLiveStream()+ (the live
18
20
  // stream's close, the stream left as it is) and +workerGone(id)+ the page's.
19
21
  // +alert+ / +confirm+: the browser's.
20
22
  export function createSessionActions({
21
23
  selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
22
- select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
24
+ select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
23
25
  alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
24
26
  }) {
25
27
  // The session this tab is stopping: its stream may close before the stop
@@ -105,8 +107,11 @@ export function createSessionActions({
105
107
  return;
106
108
  }
107
109
  await select(next);
108
- if (selected() === next) fillOpener(created);
109
- toast(continuedText(id, created), null, { sticky });
110
+ // A draft carried over from elsewhere gave way to the opener: the
111
+ // toast brings it back, and stays until closed so it can't be lost.
112
+ const displaced = selected() === next ? fillOpener(created, id) : null;
113
+ const restore = displaced ? { label: "Restore draft", run: () => restoreDraft(displaced, openerText(created)) } : null;
114
+ toast(continuedText(id, created), restore, { sticky: sticky || !!displaced });
110
115
  } catch (e) {
111
116
  const refusal = continueRefusal(e, sessions());
112
117
  if (refusal.kind === "open") {
@@ -71,13 +71,21 @@ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking",
71
71
  // full text. None when there is nothing to say. A closed step's text is
72
72
  // set whole (.textContent): its refs (ref_scan.js) split it into spans
73
73
  // that a text part's update would leave behind. +at+: a reloaded step's
74
- // message index, for its refs (data-answer-at, as an answer's).
75
- function textTemplate(text, { live, sentences, placeholder, at = null }) {
74
+ // message index, for its refs (data-answer-at, as an answer's). A
75
+ // reloaded step's +rendered+ markdown (its message's html, sanitised on the
76
+ // server, as an answer's) shows in place of the text, which it keeps as
77
+ // its source (stepText); a live step's renders once it closed
78
+ // (markdown_place.js renderStepTexts).
79
+ function textTemplate(text, { live, sentences, placeholder, at = null, rendered = null }) {
76
80
  if (live && sentences?.length) {
77
81
  return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
78
82
  }
79
83
  if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
80
- return text ? html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>` : nothing;
84
+ if (!text) return nothing;
85
+ if (typeof rendered === "string" && rendered) {
86
+ return html`<div class="gen-text markdown md-compact" data-answer-at=${at ?? nothing} data-copy-source=${text} .innerHTML=${rendered}></div>`;
87
+ }
88
+ return html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>`;
81
89
  }
82
90
 
83
91
  // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
@@ -97,7 +105,7 @@ export function stepTemplate(gen, st = {}) {
97
105
  const failed = !live && model.failedCount(gen.tools) > 0;
98
106
  // A step left with its thinking only reads "thinking" once.
99
107
  const wrapped = live || !!text || gen.tools.length > 0;
100
- return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
108
+ return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at, rendered: gen.html })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
101
109
  }
102
110
 
103
111
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
@@ -807,10 +815,11 @@ function defaultPlaceAnswer(appendToHistory) {
807
815
 
808
816
  // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
809
817
  // cards count), "" when it has none: a reloaded question card's asking
810
- // reply.
818
+ // reply, as written (a rendered step keeps it as data-copy-source).
811
819
  export function stepText(block, iteration) {
812
820
  const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
813
- return step?.querySelector(":scope > .gen-text")?.textContent || "";
821
+ const text = step?.querySelector(":scope > .gen-text");
822
+ return text?.getAttribute("data-copy-source") ?? text?.textContent ?? "";
814
823
  }
815
824
 
816
825
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
@@ -885,7 +894,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
885
894
  if (plainThinking) {
886
895
  parts.push(thinkingTemplate({ body: only.thinking, cls: "bubble thinking" }));
887
896
  } else if (group.steps.length) {
888
- const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
897
+ // A step's text with its message's rendered markdown (html: web.markdown on).
898
+ const gens = group.steps.map((step) => ({
899
+ text: step.i === null ? "" : String(items[step.i].content ?? ""),
900
+ html: step.i === null ? null : items[step.i].html ?? null,
901
+ thinking: step.thinking || "", tools: step.tools,
902
+ }));
889
903
  const steps = gens.map((gen, k) => stepTemplate(gen, {
890
904
  // The steers the step answered first, where the live rows went.
891
905
  steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
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.99.0
4
+ version: 0.100.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -266,6 +266,8 @@ files:
266
266
  - lib/samagotchi/bundles/system/manifest.yml
267
267
  - lib/samagotchi/bundles/system/memory_guide.md
268
268
  - lib/samagotchi/bundles/system/self_map.md
269
+ - lib/samagotchi/bundles/work-cap/manifest.yml
270
+ - lib/samagotchi/bundles/work-cap/plugin.rb
269
271
  - lib/samagotchi/cache_confirm.rb
270
272
  - lib/samagotchi/cancellation_controller.rb
271
273
  - lib/samagotchi/child_env.rb
@@ -523,6 +525,7 @@ files:
523
525
  - lib/samagotchi/session_metrics/model_segment.rb
524
526
  - lib/samagotchi/session_metrics/side_totals.rb
525
527
  - lib/samagotchi/session_metrics/totals.rb
528
+ - lib/samagotchi/session_metrics/usage.rb
526
529
  - lib/samagotchi/session_model_settings.rb
527
530
  - lib/samagotchi/session_observer.rb
528
531
  - lib/samagotchi/session_retention.rb