samagotchi 0.33.0 → 0.35.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +6 -1
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +5 -0
- data/docs/guardrails.md +35 -3
- data/docs/hooks.md +25 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +78 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +15 -9
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/children_status.rb +143 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +45 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +14 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/plugin/sessions.rb +45 -1
- data/lib/samagotchi/process_group.rb +2 -5
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +21 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +103 -14
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +173 -309
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +24 -1
|
@@ -9,15 +9,13 @@
|
|
|
9
9
|
// A block that holds one gen with no thinking and no tool rows hides its
|
|
10
10
|
// chrome (CSS :has), so a plain answer streams like today's bubble, and is
|
|
11
11
|
// removed at the end: the answer bubble takes its place.
|
|
12
|
-
import { toolName } from "./activity.js";
|
|
13
12
|
import { leavesBlock } from "./card.js";
|
|
14
13
|
import { copySourceAttr } from "./copy.js";
|
|
15
|
-
import { commandBlockHtml, rowHover } from "./command_view.js";
|
|
16
|
-
import { activityDiffHtml } from "./diff_view.js";
|
|
17
14
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
|
|
18
15
|
import { thumbsHtml } from "./images.js";
|
|
19
16
|
import { shouldFollowScroll } from "./scroll.js";
|
|
20
|
-
import {
|
|
17
|
+
import { hookNoticeLabel } from "./turn_events.js";
|
|
18
|
+
import { emptyAnswerHtml, htmlElement, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
|
|
21
19
|
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
22
20
|
import { createSentenceFeed } from "./sentences.js";
|
|
23
21
|
import { createTicker } from "./thinking_ticker.js";
|
|
@@ -25,81 +23,31 @@ import * as model from "./turn_model.js";
|
|
|
25
23
|
import { createHold } from "./hold.js";
|
|
26
24
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
27
25
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
26
|
+
// The parts of a drawn tool row a user may have changed (the command's
|
|
27
|
+
// raw toggle, an opened diff) or that never change once there (the
|
|
28
|
+
// thumbs): a redraw keeps them as they are.
|
|
29
|
+
const KEPT_PARTS = ["activity-command", "activity-diff", "thumbs"];
|
|
30
|
+
|
|
31
|
+
const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
|
|
32
|
+
|
|
33
|
+
// A live tool row drawn again from its activity row (status, title,
|
|
34
|
+
// output): the reloaded row's markup (toolRowInnerHtml), the kept parts
|
|
35
|
+
// and the stop-task button carried over. +html+ is that markup.
|
|
36
|
+
function redrawRow(el, html) {
|
|
37
|
+
const fresh = htmlElement(`<div>${html}</div>`);
|
|
38
|
+
for (const cls of KEPT_PARTS) {
|
|
39
|
+
const old = childWith(el, cls);
|
|
40
|
+
const drawn = childWith(fresh, cls);
|
|
41
|
+
if (old && drawn) drawn.replaceWith(old);
|
|
42
|
+
}
|
|
43
|
+
const stop = childWith(el, "task-stop");
|
|
44
|
+
el.replaceChildren(...fresh.childNodes);
|
|
45
|
+
if (stop) rowAnchor(el).after(stop);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
function statusWordHtml({ cls, word }) {
|
|
48
|
-
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// A live tool row filled from its activity row (status, title, output).
|
|
52
|
-
function fillActivityRow(el, row, sessionId) {
|
|
53
|
-
const parts = statusParts(row.status);
|
|
54
|
-
const statusEl = el.querySelector(".activity-status");
|
|
55
|
-
statusEl.className = `activity-status ${parts.cls}`;
|
|
56
|
-
statusEl.setAttribute("role", "img");
|
|
57
|
-
statusEl.setAttribute("aria-label", parts.label);
|
|
58
|
-
statusEl.title = parts.label;
|
|
59
|
-
// The exception's word after the title (reloadRowHtml's place); never
|
|
60
|
-
// beside a running task_wait's stop button, which needs "running".
|
|
61
|
-
let wordEl = el.querySelector(":scope > .activity-state");
|
|
62
|
-
if (!parts.word) wordEl?.remove();
|
|
63
|
-
else {
|
|
64
|
-
if (!wordEl) {
|
|
65
|
-
wordEl = document.createElement("span");
|
|
66
|
-
(el.querySelector(":scope > .activity-params") || el.querySelector(":scope > .activity-tool")).after(wordEl);
|
|
67
|
-
}
|
|
68
|
-
wordEl.className = `activity-state ${parts.cls}`;
|
|
69
|
-
wordEl.textContent = parts.word;
|
|
70
|
-
}
|
|
71
|
-
const toolEl = el.querySelector(".activity-tool");
|
|
72
|
-
if (toolEl) toolEl.textContent = toolName(row);
|
|
73
|
-
const paramsEl = el.querySelector(".activity-params");
|
|
74
|
-
if (paramsEl) {
|
|
75
|
-
// The title in place of the params; the full command (else the full
|
|
76
|
-
// params line) on hover.
|
|
77
|
-
paramsEl.textContent = row.title || row.params || "";
|
|
78
|
-
paramsEl.title = rowHover(row);
|
|
79
|
-
}
|
|
80
|
-
const outEl = el.querySelector(".activity-output");
|
|
81
|
-
// The call's full command, above its output (running too).
|
|
82
|
-
if (row.view?.command && !el.querySelector(".activity-command")) {
|
|
83
|
-
if (outEl) outEl.insertAdjacentHTML("beforebegin", commandBlockHtml(row.view, row.title));
|
|
84
|
-
else el.insertAdjacentHTML("beforeend", commandBlockHtml(row.view, row.title));
|
|
85
|
-
}
|
|
86
|
-
if (outEl) {
|
|
87
|
-
if (row.status === "running") {
|
|
88
|
-
outEl.style.display = "none";
|
|
89
|
-
outEl.textContent = "";
|
|
90
|
-
outEl.title = "";
|
|
91
|
-
} else {
|
|
92
|
-
// dispatch prefixes output with "[tool]"; strip that noise for display.
|
|
93
|
-
const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
94
|
-
outEl.title = full;
|
|
95
|
-
outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
|
|
96
|
-
outEl.style.display = full ? "" : "none";
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// The images a tool returned (a reloaded row has them too, reloadRowHtml).
|
|
100
|
-
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
101
|
-
// An edit/write's change, collapsed: "diff +3 −1".
|
|
102
|
-
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
48
|
+
// Where a row's stop-task button goes: after its title (else its tool).
|
|
49
|
+
function rowAnchor(el) {
|
|
50
|
+
return childWith(el, "activity-params") || childWith(el, "activity-tool");
|
|
103
51
|
}
|
|
104
52
|
|
|
105
53
|
// The container seams (the stage view composes this view, stage_view.js):
|
|
@@ -423,6 +371,11 @@ export function createTurnView({
|
|
|
423
371
|
onChange();
|
|
424
372
|
}
|
|
425
373
|
|
|
374
|
+
// A tool row is the reloaded row's markup (turn_html.js toolRowHtml),
|
|
375
|
+
// drawn again when an event changed it.
|
|
376
|
+
const rowHtml = new WeakMap();
|
|
377
|
+
const rowThumbs = (images) => thumbsHtml(sessionId(), images);
|
|
378
|
+
|
|
426
379
|
function toolEvent(r) {
|
|
427
380
|
apply(r);
|
|
428
381
|
const entry = genEls.get(r.gen);
|
|
@@ -430,24 +383,18 @@ export function createTurnView({
|
|
|
430
383
|
if (!r.gen.text) entry.text.textContent = "";
|
|
431
384
|
const body = activityEl(r.gen);
|
|
432
385
|
let el = entry.rows.get(r.row.key);
|
|
386
|
+
const html = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
|
|
433
387
|
if (!el) {
|
|
434
|
-
el =
|
|
435
|
-
el
|
|
436
|
-
el.dataset.key = r.row.key;
|
|
437
|
-
for (const [tag, cls] of [["span", "activity-status"], ["span", "activity-tool"], ["span", "activity-params"], ["div", "activity-output"]]) {
|
|
438
|
-
const part = document.createElement(tag);
|
|
439
|
-
part.className = cls;
|
|
440
|
-
el.appendChild(part);
|
|
441
|
-
}
|
|
442
|
-
el.querySelector(".activity-output").style.display = "none";
|
|
388
|
+
el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
|
|
389
|
+
rowHtml.set(el, html);
|
|
443
390
|
entry.rows.set(r.row.key, el);
|
|
444
391
|
const stick = isNearBottom();
|
|
445
392
|
body.appendChild(el);
|
|
446
|
-
fillActivityRow(el, r.row, sessionId());
|
|
447
393
|
syncTaskStop(el, r.row);
|
|
448
394
|
if (stick) follow();
|
|
449
395
|
} else {
|
|
450
|
-
|
|
396
|
+
if (rowHtml.get(el) !== html) redrawRow(el, html);
|
|
397
|
+
rowHtml.set(el, html);
|
|
451
398
|
syncTaskStop(el, r.row);
|
|
452
399
|
}
|
|
453
400
|
if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
|
|
@@ -469,7 +416,7 @@ export function createTurnView({
|
|
|
469
416
|
return;
|
|
470
417
|
}
|
|
471
418
|
const command = () => taskCommand(turn?.gens, taskId);
|
|
472
|
-
el
|
|
419
|
+
rowAnchor(el).after(taskStopButton(taskStop, taskId, { command }));
|
|
473
420
|
}
|
|
474
421
|
|
|
475
422
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
@@ -680,11 +627,11 @@ export function createTurnView({
|
|
|
680
627
|
},
|
|
681
628
|
toolStarted(data) {
|
|
682
629
|
if (!turn) this.turnStarted({});
|
|
683
|
-
toolEvent(model.toolStarted(turn, data));
|
|
630
|
+
toolEvent(model.toolStarted(turn, data, Date.now()));
|
|
684
631
|
},
|
|
685
632
|
toolCompleted(data) {
|
|
686
633
|
if (!turn) this.turnStarted({});
|
|
687
|
-
toolEvent(model.toolCompleted(turn, data));
|
|
634
|
+
toolEvent(model.toolCompleted(turn, data, Date.now()));
|
|
688
635
|
},
|
|
689
636
|
turnEnded,
|
|
690
637
|
notice,
|
|
@@ -754,35 +701,6 @@ function setLabel(summary, gen) {
|
|
|
754
701
|
summary.innerHTML = html;
|
|
755
702
|
}
|
|
756
703
|
|
|
757
|
-
// A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, the full command (an
|
|
758
|
-
// execute/task_create's view), output (its "[tool]" tag dropped, 300 chars, the rest on hover), plus the call's
|
|
759
|
-
// duration from its record.
|
|
760
|
-
// Params, output and the call's images come from the saved messages'
|
|
761
|
-
// parts; without them the row has the record's name, status and duration
|
|
762
|
-
// only. +thumbs(images)+ draws the images, as the live row does.
|
|
763
|
-
function reloadRowHtml(r, thumbs) {
|
|
764
|
-
const shown = r.title || r.params;
|
|
765
|
-
const hover = rowHover(r) ? ` title="${escapeHtml(rowHover(r))}"` : "";
|
|
766
|
-
const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
|
|
767
|
-
const duration = formatDuration(r.duration_ms);
|
|
768
|
-
const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
769
|
-
const output = full
|
|
770
|
-
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
771
|
-
: "";
|
|
772
|
-
const status = statusParts(r.status);
|
|
773
|
-
return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
|
|
774
|
-
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
775
|
-
`${commandBlockHtml(r.view, r.title)}${output}` +
|
|
776
|
-
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// The notice of a turn that ended with no answer: a muted row like the
|
|
780
|
-
// loop's retry row, where the answer would be (live: promote()).
|
|
781
|
-
export const EMPTY_ANSWER_CLASS = "bubble hook-notice empty-answer";
|
|
782
|
-
function emptyAnswerHtml(item) {
|
|
783
|
-
return `<div class="${EMPTY_ANSWER_CLASS}">${escapeHtml(emptyAnswerLine(item))}</div>`;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
704
|
// A reloaded history in the turn view (renderHistory's object path): per
|
|
787
705
|
// turn the prompt, a collapsed block of its steps when it had any (texts,
|
|
788
706
|
// and with the server's parts the thinking, params and output, from the
|
|
@@ -805,12 +723,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
805
723
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
806
724
|
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
807
725
|
};
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
const originLabelHtml = (m) => {
|
|
811
|
-
const label = sourceLabel(m?.source);
|
|
812
|
-
return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
|
|
813
|
-
};
|
|
726
|
+
// A user's line, labelled when chi brought it in (a delegate report).
|
|
727
|
+
const userHtml = (m, opts = {}) => userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
|
|
814
728
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
815
729
|
// the turn's answer (shown anyway).
|
|
816
730
|
const reportReplyHtml = (i, group) => {
|
|
@@ -829,7 +743,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
829
743
|
// A context wake turn starts with its note.
|
|
830
744
|
const parts = [prompt.role === "note"
|
|
831
745
|
? `<div class="bubble note">${noteHtml(prompt)}</div>`
|
|
832
|
-
:
|
|
746
|
+
: userHtml(prompt)];
|
|
833
747
|
const [only] = group.steps;
|
|
834
748
|
const ended = group.answer !== null || group.emptyAnswer != null;
|
|
835
749
|
const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
|
|
@@ -850,7 +764,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
850
764
|
const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
|
|
851
765
|
// The steers the step answered first, where the live rows went.
|
|
852
766
|
const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
|
|
853
|
-
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) =>
|
|
767
|
+
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => toolRowHtml(r, { thumbs })).join("")}</div>` : "";
|
|
854
768
|
const failed = model.failedCount(gen.tools) ? " has-failed" : "";
|
|
855
769
|
return `<details class="gen${failed}">${labelHtml(gen)}${steers}${thinking}${text}${tools}</details>`;
|
|
856
770
|
});
|
|
@@ -866,7 +780,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
866
780
|
// small model tells its user about a report before its next tool call,
|
|
867
781
|
// and that step's text would stay folded in the block.
|
|
868
782
|
parts.push((group.merged || []).map((i) =>
|
|
869
|
-
|
|
783
|
+
userHtml(items[i], { state: "steered", step: items[i].step }) +
|
|
870
784
|
reportReplyHtml(i, group)).join(""));
|
|
871
785
|
// A turn that ended with no answer: chi's muted notice where the answer
|
|
872
786
|
// would be, before the timing, as live.
|
|
@@ -49,6 +49,10 @@ module Samagotchi
|
|
|
49
49
|
muted_memory_names: Array(session.muted_memory_names),
|
|
50
50
|
# The session that delegated this one (the `delegate` tool), else nil.
|
|
51
51
|
parent_id: session.parent_id,
|
|
52
|
+
# Started by the delegate tool (Session#delegate?); a fork has a
|
|
53
|
+
# parent_id too but isn't one. The parent's children chip counts
|
|
54
|
+
# delegates only, as the TUI's segment and /children do.
|
|
55
|
+
delegate: session.delegate?,
|
|
52
56
|
first_preview: first_preview_for(session),
|
|
53
57
|
owner: owner&.kind,
|
|
54
58
|
# The saved recap's first sentence, for the session card.
|