samagotchi 0.66.0 → 0.67.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +71 -29
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +17 -3
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. metadata +14 -1
@@ -36,7 +36,7 @@ import { createShownTexts } from "./shown_texts.js";
36
36
  import { MODEL_KEY } from "./model_pick.js";
37
37
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
38
  import { createModelPicker } from "./model_picker.js";
39
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText } from "./images.js";
39
+ import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
40
40
  import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
41
  import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
42
  import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
@@ -291,13 +291,6 @@ const questionCards = createQuestionCards({
291
291
  removeHintIfEmpty: () => removeHintIfEmpty(),
292
292
  askingText: () => turnDom.askingText(),
293
293
  });
294
- // The event id (or, from an older worker, the event_seq) the selected session
295
- // was rendered at (null: no live worker then), where a stream opened later
296
- // has to start.
297
- let selectSeq = null;
298
- // The stream was opened from 0 (a wake with no cursor to go on) and no
299
- // new turn has started on it yet: see followWokenWorker.
300
- let streamFromGuess = false;
301
294
  // The enqueued_ids this page sent (from the /turn acks): only these come back
302
295
  // into the composer when their turn fails.
303
296
  const sentIds = new Set();
@@ -383,8 +376,9 @@ function openSessionEvents() {
383
376
  // never forcing it) or when a newer chi is installed than chi web runs.
384
377
  // Once per notice per tab: every reconnect's snapshot says it again.
385
378
  const versions = { served: null, installed: null };
386
- // The session whose Restart is under way (its button says so).
387
- let restartingId = null;
379
+ // The sessions whose Restart is under way (their button says so): one
380
+ // session's restart never blocks another's.
381
+ const restartingIds = new Set();
388
382
  const versionNoticesShown = new Set();
389
383
  function noticeVersions(data) {
390
384
  if (typeof data.version === "string") versions.served = data.version;
@@ -767,8 +761,8 @@ async function resync({ endText = null } = {}) {
767
761
  renderSessionView(data, { kept });
768
762
  // Preview is read-only: a worker is woken only by POST /turn, so attach
769
763
  // only when a live bridge exists.
770
- selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
771
- if (selectSeq !== null) startStream(id, selectSeq);
764
+ current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
765
+ if (current.streamSeq !== null) startStream(id, current.streamSeq);
772
766
  else if (endText) addEndBubble(endText);
773
767
  } finally {
774
768
  resyncsInFlight -= 1;
@@ -1375,8 +1369,7 @@ function removeRecap() {
1375
1369
  }
1376
1370
 
1377
1371
  // The info bar's "copy chi --attach": the full id, which the page shows
1378
- // only short; "copied ✓" for 2 s.
1379
- let attachCopiedAt = 0;
1372
+ // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1380
1373
  infoTextEl.addEventListener("click", (e) => {
1381
1374
  if (e.target.closest(".worker-restart")) restartWorker();
1382
1375
  // Pushed as openAllView does, so the view's back returns here.
@@ -1387,8 +1380,9 @@ infoTextEl.addEventListener("click", (e) => {
1387
1380
  });
1388
1381
  infoTextEl.addEventListener("click", async (e) => {
1389
1382
  if (!e.target.closest(".copy-attach") || !selected) return;
1390
- if (!(await copyText(`chi --attach ${selected}`))) return;
1391
- attachCopiedAt = Date.now();
1383
+ const id = selected;
1384
+ if (!(await copyText(`chi --attach ${id}`)) || id !== selected) return;
1385
+ current.attachCopiedAt = Date.now();
1392
1386
  updateInfoBar();
1393
1387
  setTimeout(updateInfoBar, 2000);
1394
1388
  });
@@ -1405,10 +1399,10 @@ function currentWorkerBadge() {
1405
1399
  // follows it (its bridge_up), the composer's draft stays as it is.
1406
1400
  async function restartWorker() {
1407
1401
  const badge = currentWorkerBadge();
1408
- if (!badge || !badge.restart || restartingId) return;
1402
+ if (!badge || !badge.restart || restartingIds.has(selected)) return;
1409
1403
  if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
1410
1404
  const id = selected;
1411
- restartingId = id;
1405
+ restartingIds.add(id);
1412
1406
  updateInfoBar();
1413
1407
  try {
1414
1408
  const result = await restartSession(id);
@@ -1416,7 +1410,7 @@ async function restartWorker() {
1416
1410
  } catch (e) {
1417
1411
  showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1418
1412
  } finally {
1419
- restartingId = null;
1413
+ restartingIds.delete(id);
1420
1414
  updateInfoBar();
1421
1415
  }
1422
1416
  }
@@ -1465,7 +1459,7 @@ function updateInfoBar() {
1465
1459
  // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1466
1460
  renderInfoBar(infoTextEl, {
1467
1461
  id: selected,
1468
- copied: Date.now() - attachCopiedAt < 2000,
1462
+ copied: Date.now() - current.attachCopiedAt < 2000,
1469
1463
  status: current.status,
1470
1464
  model: modelLabel(current.model, ...(current.served || [])),
1471
1465
  // A link of a session chain: ← the previous link, → the next one (each
@@ -1488,7 +1482,7 @@ function updateInfoBar() {
1488
1482
  ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1489
1483
  speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1490
1484
  clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1491
- worker: badge ? { badge, busy: restartingId === selected } : null,
1485
+ worker: badge ? { badge, busy: restartingIds.has(selected) } : null,
1492
1486
  });
1493
1487
  updateSessionTitle();
1494
1488
  }
@@ -1577,12 +1571,12 @@ function setUsedMemories(names) {
1577
1571
  // worker have been up already, turn_started says so (replaysDrawnTurn).
1578
1572
  function followWokenWorker() {
1579
1573
  if (streamControl?.live) return;
1580
- startStream(selected, selectSeq ?? 0);
1581
- streamFromGuess = selectSeq === null;
1574
+ startStream(selected, current.streamSeq ?? 0);
1575
+ current.streamFromGuess = current.streamSeq === null;
1582
1576
  }
1583
1577
 
1584
1578
  function startStream(id, fromSeq) {
1585
- streamFromGuess = false;
1579
+ current.streamFromGuess = false;
1586
1580
  if (streamControl) {
1587
1581
  streamControl.close();
1588
1582
  streamControl = null;
@@ -1652,8 +1646,8 @@ const streamHandlers = createStreamHandlers({
1652
1646
  earlyRestores,
1653
1647
  stream: {
1654
1648
  takeGuess: () => {
1655
- const guess = streamFromGuess;
1656
- streamFromGuess = false;
1649
+ const guess = current.streamFromGuess;
1650
+ current.streamFromGuess = false;
1657
1651
  return guess;
1658
1652
  },
1659
1653
  restart: () => {
@@ -1806,18 +1800,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1806
1800
  updateInfoBar();
1807
1801
  }
1808
1802
  actionBtn.disabled = true;
1803
+ // The session the line goes to: the page may switch to another while an
1804
+ // image uploads or the turn is sent, and what follows stays with this one.
1809
1805
  const sentTo = selected;
1810
1806
  try {
1811
- for (const chip of sending) {
1812
- if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1813
- }
1814
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1807
+ await uploadChips(sending, sentTo, { upload: uploadImage });
1808
+ const ack = await sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1815
1809
  if (ack?.command_id) {
1816
1810
  // The worker knew it as a command (its list was newer than ours):
1817
1811
  // its command_ran shows it, not a prompt bubble.
1818
1812
  echo.remove();
1819
1813
  clearComposer();
1820
- followWokenWorker();
1814
+ if (sentTo === selected) followWokenWorker();
1821
1815
  return;
1822
1816
  }
1823
1817
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
@@ -1834,7 +1828,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1834
1828
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1835
1829
  fitPrompt();
1836
1830
  clearChips();
1837
- if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
1831
+ if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1838
1832
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1839
1833
  // worker that wasn't live (no turn_enqueued then).
1840
1834
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
@@ -1843,17 +1837,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1843
1837
  // A live stream carries this turn already; restarting it would drop the
1844
1838
  // events in between. Without one, POST /turn just woke a worker: its
1845
1839
  // events start after the seq the session was rendered at (0 for a new
1846
- // worker process).
1847
- followWokenWorker();
1840
+ // worker process). A switch since then opened the other session's own.
1841
+ if (sentTo === selected) followWokenWorker();
1848
1842
  } catch (e) {
1849
1843
  echo.remove();
1850
1844
  // The turn never reached the worker's queue: an early restore waiting
1851
1845
  // for its ack is never taken back (4.13), so drop it here. The prompt
1852
1846
  // is still in the composer, as the echo was removed.
1853
1847
  dropEarlyRestores(earlyRestores);
1854
- // The chips stay (uploaded ones keep their ref) for another try: an
1848
+ // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1855
1849
  // image refused (bad_image, too_large, bad_images) says why below.
1856
1850
  if (e.code === "owned_by_tui") {
1851
+ if (sentTo !== selected) return alert(e.message);
1857
1852
  current.owner = "tui";
1858
1853
  updateComposerMode();
1859
1854
  return;
@@ -1862,7 +1857,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1862
1857
  // says so, and the line stays in the composer.
1863
1858
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1864
1859
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1865
- if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1860
+ if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1866
1861
  alert(e.message);
1867
1862
  } finally {
1868
1863
  actionBtn.disabled = current.owner === "tui";
@@ -141,7 +141,7 @@ export function allViewChains(fams, all, { query = "", includeArchived = false }
141
141
 
142
142
  // The links a family's chain list shows: the chain but its head, newest
143
143
  // first, each {session, day} (day: its 1-based place in the chain).
144
- export function chainOthers(f) {
144
+ function chainOthers(f) {
145
145
  const links = f?.chain?.links || [];
146
146
  return links.map((session, i) => ({ session, day: i + 1 }))
147
147
  .filter(({ session }) => session.id !== f.head.id)
@@ -153,8 +153,8 @@ export function continueLine(choice, reason = "") {
153
153
  // dropped (styled as failed). A change that restarts the prompt cache asks
154
154
  // first (CacheConfirm): `confirm` is the line that applies it, and this
155
155
  // tab's own bubble (`own`) gets an Apply button that sends it.
156
- export const QUEUED_TEXT = "queued: runs after this turn";
157
- export const APPLY_TEXT = "Apply (restarts the cache)";
156
+ const QUEUED_TEXT = "queued: runs after this turn";
157
+ const APPLY_TEXT = "Apply (restarts the cache)";
158
158
 
159
159
  export function commandView(event, myId) {
160
160
  const own = isOwn(event.client_id, myId);
@@ -2,8 +2,8 @@
2
2
  // its top edge sets a taller floor (the cap then is at least that floor).
3
3
  export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
4
4
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
5
- export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
- export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
5
+ const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
+ const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
7
7
 
8
8
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
9
9
  // grip's height, or null; minPx: the empty box's height.
@@ -99,7 +99,7 @@ function makeButton(doc, kind) {
99
99
 
100
100
  // Buttons on every bubble and code block in +root+ (and on +root+ itself)
101
101
  // that has none yet.
102
- export function decorate(root) {
102
+ function decorate(root) {
103
103
  const doc = root.ownerDocument;
104
104
  const bubbles = [...root.querySelectorAll(BUBBLES)];
105
105
  if (root.matches(BUBBLES)) bubbles.push(root);
@@ -13,7 +13,7 @@ function num(v) {
13
13
 
14
14
  // What a fill counts against: the window, or the budget when one is set
15
15
  // and smaller; null without a window.
16
- export function countedTokens(windowTokens, budgetTokens = null) {
16
+ function countedTokens(windowTokens, budgetTokens = null) {
17
17
  const window = num(windowTokens);
18
18
  if (!window || window <= 0) return null;
19
19
  const budget = num(budgetTokens);
@@ -21,8 +21,8 @@ import { html, nothing, render } from "./vendor/lit-html.js";
21
21
  export const QUEUE_LABEL = "queue for after the turn";
22
22
  export const CUT_LABEL = "cut in now";
23
23
  // A phone's row (≤ 600 px) is short: its text and chips.
24
- export const QUEUE_SHORT = "queue";
25
- export const CUT_SHORT = "cut in";
24
+ const QUEUE_SHORT = "queue";
25
+ const CUT_SHORT = "cut in";
26
26
  // Alt+Enter to a worker without the delivery feature: not sent (a queue
27
27
  // would steer it), the text kept, this said.
28
28
  export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
@@ -144,7 +144,7 @@ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
144
144
  }
145
145
 
146
146
  // The idle row: the key hint (none on a phone: no keyboard keys there).
147
- export function idleTemplate() {
147
+ function idleTemplate() {
148
148
  return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
149
149
  }
150
150
 
@@ -155,7 +155,7 @@ export function idleTemplate() {
155
155
  // phase, gen, tool, cutAfter, serverGen, canQueue, model)
156
156
  // composerEmpty() nothing typed and no image chips: a link arms
157
157
  // send(delivery) send the composer's line with +delivery+ ("cut", "queue")
158
- export const TICK_MS = 500;
158
+ const TICK_MS = 500;
159
159
 
160
160
  export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
161
161
  setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
@@ -71,7 +71,7 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
71
71
  }
72
72
 
73
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
74
- export const PHONE_MAX_PX = 600;
74
+ const PHONE_MAX_PX = 600;
75
75
 
76
76
  // The strip's family popover: one #familyPop in body (position: fixed, so
77
77
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
@@ -1,7 +1,7 @@
1
1
  import { waitingOn } from "./notify.js";
2
2
 
3
3
  export const PREVIEW_CHARS = 40;
4
- export const FIRST_PREVIEW_CHARS = 80;
4
+ const FIRST_PREVIEW_CHARS = 80;
5
5
 
6
6
  export function escapeHtml(value) {
7
7
  return String(value).replace(
@@ -37,20 +37,24 @@ export function normalize(s) {
37
37
  return String(s || "").replace(/\s+/g, " ").trim();
38
38
  }
39
39
 
40
+ // +text+ in at most +n+ characters: whole when it fits, else cut with "…"
41
+ // as its last (the kept part's trailing spaces dropped).
42
+ export function clip(text, n) {
43
+ const s = String(text ?? "");
44
+ return s.length > n ? `${s.slice(0, n - 1).trimEnd()}\u2026` : s;
45
+ }
46
+
40
47
  export function previewOf(lastPrompt) {
41
48
  const normalized = normalize(lastPrompt);
42
49
  if (!normalized) return "\u2014";
43
- if (normalized.length > PREVIEW_CHARS) {
44
- return `${normalized.slice(0, PREVIEW_CHARS)}\u2026`;
45
- }
46
- return normalized;
50
+ return clip(normalized, PREVIEW_CHARS + 1);
47
51
  }
48
52
 
49
53
  // A session's first message as the info bar's title: whitespace collapsed,
50
54
  // cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
51
55
  export function firstPreview(text) {
52
56
  const n = normalize(text);
53
- return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
57
+ return clip(n, FIRST_PREVIEW_CHARS + 1);
54
58
  }
55
59
 
56
60
  export function previewForCard(session) {
@@ -66,7 +70,7 @@ const DELETE_PREVIEW_CHARS = 40;
66
70
  // "stopped if it's still running", never promised.
67
71
  export function deleteConfirmText(session) {
68
72
  const n = normalize(session.first_preview || session.last_prompt || "");
69
- const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
73
+ const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
70
74
  const shortId = session.short_id || String(session.id).slice(0, 8);
71
75
  const lines = [
72
76
  `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
@@ -67,7 +67,30 @@ export function turnRefs(chips) {
67
67
  }
68
68
 
69
69
  // Chips again for a prompt the worker handed back (its images are stored
70
- // already, so they need no upload).
70
+ // already, so they need no upload: to that session).
71
71
  export function restoredChips(sessionId, images) {
72
- return (images || []).filter((i) => i && i.file).map((ref) => ({ name: ref.name || "image", ref, src: imageUrl(sessionId, ref) }));
72
+ return (images || []).filter((i) => i && i.file)
73
+ .map((ref) => ({ name: ref.name || "image", ref, session: sessionId, src: imageUrl(sessionId, ref) }));
74
+ }
75
+
76
+ // A restored chip has no file of its own: its image, read back from where
77
+ // its session stores it.
78
+ async function fetchChipFile(src, name) {
79
+ const res = await fetch(src);
80
+ if (!res.ok) throw new Error(`Can't read ${name} again (${res.status})`);
81
+ const blob = await res.blob();
82
+ return new File([blob], name, { type: blob.type });
83
+ }
84
+
85
+ // Uploads the chips a turn to +sessionId+ needs: a ref is into one session's
86
+ // image store (chip.session), so a chip uploaded to another one (a failed
87
+ // send there, then a switch) goes up again, to this one. Sets each chip's
88
+ // ref and session in place: a failed send keeps them for another try.
89
+ export async function uploadChips(chips, sessionId, { upload, fetchFile = fetchChipFile }) {
90
+ for (const chip of chips || []) {
91
+ if (chip.ref && chip.session === sessionId) continue;
92
+ const file = chip.file || await fetchFile(chip.src, chip.name);
93
+ chip.ref = await upload(sessionId, file, chip.name);
94
+ chip.session = sessionId;
95
+ }
73
96
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { html, nothing, render } from "./vendor/lit-html.js";
16
16
 
17
- export const STRATEGIES = [
17
+ const STRATEGIES = [
18
18
  { value: "none", label: "none" },
19
19
  { value: "stale", label: "stale" },
20
20
  { value: "stale,forget", label: "stale + forget (experimental)" },
@@ -144,7 +144,7 @@ export function choiceWords(choice) {
144
144
 
145
145
  // The strategy list, with the session's own value when it isn't one of them
146
146
  // (forget alone, a newer chi's layer).
147
- export function strategyOptions(chosen) {
147
+ function strategyOptions(chosen) {
148
148
  if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
149
149
  return [...STRATEGIES, { value: chosen, label: chosen }];
150
150
  }
@@ -2,6 +2,8 @@
2
2
  // summaries (GET /api/events). The page shows an OS notification and a
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
+ import { clip } from "./format.js";
6
+
5
7
  // The bell's state. OS notifications need a secure context: on chi web
6
8
  // over http at a LAN address the API is missing or always denied, so the
7
9
  // bell is hidden there (the title badge still counts).
@@ -16,7 +18,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
16
18
  // chi ran for a delegate child's report (last_turn origin delegate_report)
17
19
  // or an attached context change (context): nobody started it, so nobody
18
20
  // watched it.
19
- export const DONE_MIN_SECONDS = 10;
21
+ const DONE_MIN_SECONDS = 10;
20
22
  const UNWATCHED_ORIGINS = ["delegate_report", "context"];
21
23
 
22
24
  // The step-limit question (kind "continue": a turn ran out of iterations
@@ -90,7 +92,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
90
92
  // @param prev the summary held before (null for a session not seen yet)
91
93
  // @param next the new summary (null when the session is gone)
92
94
  // @return [String] keys, maybe none
93
- export function closedKeys(prev, next) {
95
+ function closedKeys(prev, next) {
94
96
  const keys = [];
95
97
  const q = prev?.pending_question;
96
98
  const nq = next?.pending_question;
@@ -147,7 +149,7 @@ export function trackAttention(state, type, data = {}) {
147
149
  // No question or answer text (it would show on a lock screen).
148
150
  export function attentionText(session, attention) {
149
151
  const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
150
- const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
152
+ const title = clip(name, 61);
151
153
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
152
154
  const body = {
153
155
  question: "needs an answer",
@@ -171,7 +173,7 @@ export function attentionText(session, attention) {
171
173
  // lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
172
174
  // tab goes on alone, at once.
173
175
  export const NOTIFY_CHANNEL = "chi-notify";
174
- export const NOTIFY_HOLD_MS = 300;
176
+ const NOTIFY_HOLD_MS = 300;
175
177
  // How long a key seen in front is remembered (its frame may come late).
176
178
  const SEEN_TTL_MS = 10 * 60 * 1000;
177
179
 
@@ -70,7 +70,7 @@ export function approvalView(pq) {
70
70
  const reason = a.reason ? String(a.reason) : "(no reason given)";
71
71
  return {
72
72
  // A plugin tool's label ("chrome: screenshot"), as its row shows it;
73
- // else the tool the call acts as (an MCP tool behind mcp_call).
73
+ // else the tool the call acts as (an MCP tool behind call_tool).
74
74
  tool: a.label || a.acts_as || a.tool || "",
75
75
  // A tool without a command or path (an MCP tool): its compact args.
76
76
  what: command || paths.join("\n") || a.args || "",
@@ -30,7 +30,7 @@ const SKIP = "pre, a, [data-ref-kind]";
30
30
  // The ref a match names: { path, line, endLine } or null. A path has a `/`
31
31
  // or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
32
32
  // a ref; an end line not after the line is dropped.
33
- export function refOf(groups) {
33
+ function refOf(groups) {
34
34
  const path = groups?.path || "";
35
35
  if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
36
36
  if (groups.from === undefined) return { path, line: null, endLine: null };
@@ -50,6 +50,15 @@ export function emptySessionState() {
50
50
  reasoningHistory: null,
51
51
  toolkit: null,
52
52
  timing: normalizeTiming(),
53
+ // The event id (or, from an older worker, the event_seq) the session was
54
+ // rendered at (null: no live worker then), where a stream opened later
55
+ // has to start; set by the page's resync, not by the payload.
56
+ streamSeq: null,
57
+ // The stream was opened from 0 (a wake with no cursor to go on) and no
58
+ // new turn has started on it yet (app.js followWokenWorker).
59
+ streamFromGuess: false,
60
+ // When the info bar's "copy chi --attach" copied (its "copied ✓").
61
+ attachCopiedAt: 0,
53
62
  };
54
63
  }
55
64
 
@@ -65,6 +74,11 @@ export function sessionStateFrom(data) {
65
74
  served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
66
75
  dir: s.working_directory,
67
76
  owner: s.owner || null,
77
+ // Its live worker's chi and features (null and [] without one): a
78
+ // switch shows its stale-worker badge, task stop and queue at once,
79
+ // not at the session's next hub event (followSelectedSession).
80
+ workerVersion: s.worker_version || null,
81
+ workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
68
82
  timing: normalizeTiming(data.timing),
69
83
  };
70
84
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -1,4 +1,4 @@
1
- import { normalize } from "./format.js";
1
+ import { clip, normalize } from "./format.js";
2
2
 
3
3
  // Where the open session's title shows: once on screen. Its strip card says
4
4
  // it when the strip is shown and has an `.active` card for it; otherwise
@@ -21,6 +21,5 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
21
21
  export function tabTitle({ title, base }) {
22
22
  const text = normalize(title);
23
23
  if (!text) return base;
24
- const cut = text.length > TAB_TITLE_CHARS ? `${text.slice(0, TAB_TITLE_CHARS)}…` : text;
25
- return `${cut} · ${base}`;
24
+ return `${clip(text, TAB_TITLE_CHARS + 1)} · ${base}`;
26
25
  }
@@ -188,14 +188,6 @@ export function familySummary(f) {
188
188
  return delegatesSummary(f.members.map((m) => m.session));
189
189
  }
190
190
 
191
- // The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
192
- export function familyChipText(sum) {
193
- if (!sum) return "";
194
- const waiting = sum.counts?.waiting || 0;
195
- return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
196
- sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
197
- }
198
-
199
191
  // Whether a family's list is open: the user's toggle (+toggles+, head id →
200
192
  // bool) wins; else open when a member waits on the user (the head's own
201
193
  // wait is on its card already) or a search matched a member.
@@ -15,9 +15,9 @@ const FILE_TOOLS = new Set(["read", "edit", "write"]);
15
15
  const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
16
16
  const TRAIL = 3;
17
17
  // The hand-off waits this long after the stage was last in use.
18
- export const QUIET_MS = 1500;
18
+ const QUIET_MS = 1500;
19
19
  // A touch, wheel or scroll counts as use this long (phones have no hover).
20
- export const TOUCH_MS = 4000;
20
+ const TOUCH_MS = 4000;
21
21
 
22
22
  // One headline sentence as plain text: the markdown a streaming answer
23
23
  // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
@@ -3,9 +3,9 @@ import { waitingOn } from "./notify.js";
3
3
  // How many session cards the top strip shows for its width in px: a card
4
4
  // needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
5
5
  // never fewer than 3 (narrow screens scroll them), never more than 6.
6
- export const STRIP_TILE_PX = 150;
7
- export const STRIP_GAP_PX = 12;
8
- export const STRIP_CARD_PX = 360;
6
+ const STRIP_TILE_PX = 150;
7
+ const STRIP_GAP_PX = 12;
8
+ const STRIP_CARD_PX = 360;
9
9
 
10
10
  export function stripColumns(width) {
11
11
  const fit = Math.floor(((width || 0) - STRIP_TILE_PX - STRIP_GAP_PX) / (STRIP_CARD_PX + STRIP_GAP_PX));
@@ -6,9 +6,10 @@
6
6
  // button disabled. Pure but for the injected calls, so `node --test`
7
7
  // covers it; turn_view.js and stage_view.js draw the button.
8
8
  import { stopTask as postStopTask } from "./data.js";
9
+ import { clip } from "./format.js";
9
10
 
10
11
  // What a worker that has the route names in its sidecar features.
11
- export const TASK_STOP_FEATURE = "task_stop";
12
+ const TASK_STOP_FEATURE = "task_stop";
12
13
  const COMMAND_SHOWN = 120;
13
14
 
14
15
  // The task a row's button would stop: a running task_wait's (its server
@@ -61,7 +62,7 @@ export function taskCommand(gens, taskId) {
61
62
 
62
63
  export function confirmText(taskId, command) {
63
64
  const line = String(command || "").replace(/\s+/g, " ").trim();
64
- const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
65
+ const what = line ? clip(line, COMMAND_SHOWN) : taskId;
65
66
  return `Stop task ${what}?`;
66
67
  }
67
68
 
@@ -26,7 +26,7 @@ export function workerGoneText({ turnRunning, stopped = false }) {
26
26
 
27
27
  // How long a turn's answer waits for the after_turn hooks' answer_display
28
28
  // before the page stops waiting and pops what it has (4.14).
29
- export const DISPLAY_WAIT_MS = 20000;
29
+ const DISPLAY_WAIT_MS = 20000;
30
30
 
31
31
  // The wait between a turn_completed with display_pending and its
32
32
  // answer_display. The worker can die in between (a crash, a stop, an idle
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
8
8
  import { commandBlockTemplate, rowHover } from "./command_view.js";
9
9
  import { copySource } from "./copy.js";
10
10
  import { activityDiffTemplate } from "./diff_view.js";
11
- import { steerSender } from "./format.js";
11
+ import { clip, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
14
  import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
@@ -105,7 +105,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
105
105
  const duration = formatDuration(row.duration_ms);
106
106
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
107
107
  const output = full
108
- ? html`<div class="activity-output" title=${full}>${full.length > 300 ? `${full.slice(0, 300)}…` : full}</div>`
108
+ ? html`<div class="activity-output" title=${full}>${clip(full, 301)}</div>`
109
109
  : nothing;
110
110
  const status = statusParts(row.status, { noMatch: !!row.no_match });
111
111
  return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
@@ -25,7 +25,7 @@
25
25
  // - the turn's end closes the current gen.
26
26
  import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
27
27
  import { tallyText } from "./tally.js";
28
- import { leadTrimmed } from "./format.js";
28
+ import { clip, leadTrimmed } from "./format.js";
29
29
 
30
30
  const LABEL_MAX = 80;
31
31
  const LABEL_TOOLS = 3;
@@ -194,7 +194,7 @@ function isStep(gen) {
194
194
  }
195
195
 
196
196
  function cut(line) {
197
- return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
197
+ return clip(line, LABEL_MAX + 1);
198
198
  }
199
199
 
200
200
  // "3 tool calls (1 failed)": failed = status error, the tally's predicate
@@ -58,7 +58,7 @@ function closedLabel(gen) {
58
58
  // that would repeat "thinking"). +body+ is the text: a string, or the live
59
59
  // step's Text node (appended by delta: a selection in it survives).
60
60
  // +cls+ "bubble thinking" is a plain answer's collapsed thinking block.
61
- export function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
61
+ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
62
62
  const inner = html`<div class="thinking-body">${body}</div>`;
63
63
  if (!wrapped) return inner;
64
64
  const summary = line === null ? "thinking" : html`<span class="thinking-tag">thinking · </span>${line}`;
@@ -241,10 +241,6 @@ module Samagotchi
241
241
  knows_dir(InfoProbe.call(host, port, timeout: timeout))
242
242
  end
243
243
 
244
- def self.probe_verdict(status, body)
245
- knows_dir(InfoProbe.verdict(status, body))
246
- end
247
-
248
244
  def self.knows_dir(info)
249
245
  info.is_a?(Hash) && !Array(info["features"]).include?("dir") ? :other : info
250
246
  end