samagotchi 0.91.0 → 0.92.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +16 -12
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +2 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/tool-guardrails.md +10 -2
  15. data/docs/memory.md +6 -0
  16. data/docs/plugins.md +30 -13
  17. data/docs/releasing.md +3 -0
  18. data/docs/sessions.md +3 -3
  19. data/lib/samagotchi/bridge.rb +8 -9
  20. data/lib/samagotchi/bundle_command/check.rb +43 -12
  21. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  22. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  23. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  24. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  25. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  27. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  28. data/lib/samagotchi/bundle_command.rb +11 -213
  29. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  30. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  32. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  33. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  34. data/lib/samagotchi/child_env.rb +50 -0
  35. data/lib/samagotchi/engine.rb +22 -12
  36. data/lib/samagotchi/hooks/registry.rb +2 -0
  37. data/lib/samagotchi/kernel_loop.rb +14 -3
  38. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  39. data/lib/samagotchi/llm/errors.rb +4 -4
  40. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  41. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  42. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  43. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  44. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  45. data/lib/samagotchi/parent_report.rb +12 -0
  46. data/lib/samagotchi/reply_wait.rb +6 -2
  47. data/lib/samagotchi/sandbox_layout.rb +66 -0
  48. data/lib/samagotchi/send_command.rb +3 -3
  49. data/lib/samagotchi/session_manager.rb +12 -20
  50. data/lib/samagotchi/task_watch.rb +64 -21
  51. data/lib/samagotchi/testing/bundle.rb +32 -19
  52. data/lib/samagotchi/testing/install.rb +26 -17
  53. data/lib/samagotchi/testing/sandbox.rb +45 -37
  54. data/lib/samagotchi/testing/waiting.rb +5 -7
  55. data/lib/samagotchi/tool_response.rb +28 -6
  56. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  57. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/public/app.js +24 -14
  60. data/lib/samagotchi/web/public/cards_place.js +4 -1
  61. data/lib/samagotchi/web/public/images.js +6 -0
  62. data/lib/samagotchi/web/public/model_pick.js +10 -0
  63. data/lib/samagotchi/web/public/toasts.js +17 -9
  64. data/lib/samagotchi/web/public/unqueue.js +20 -4
  65. data/lib/samagotchi/worker.rb +77 -185
  66. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  67. data/lib/samagotchi/worker_inbound.rb +5 -5
  68. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  69. data/lib/samagotchi/worker_wakes.rb +0 -3
  70. metadata +6 -1
@@ -91,16 +91,25 @@ module Samagotchi
91
91
  end
92
92
  private_class_method :settled_record
93
93
 
94
+ # A command's status (its command_ran's) as a settled outcome; any
95
+ # other status (busy, dropped) is a cancel for that reason.
96
+ COMMAND_OUTCOMES = { "ok" => "completed", "error" => "failed" }.freeze
97
+ # Its record's origin: no turn ran (ParentReport says "the command").
98
+ COMMAND_ORIGIN = "command"
99
+
94
100
  # A file-delivered command ends its sender's wait without running a
95
- # turn. Keep a terminal outcome under the same per-input settled path.
96
- def self.record_command_outcome(session_dir, enqueued_id)
101
+ # turn. Keep a terminal outcome under the same per-input settled path:
102
+ # +status+ is its command_ran's ("ok", "error", "busy", "dropped" at a
103
+ # teardown), so the wait reads completed, failed, or canceled with that
104
+ # reason.
105
+ def self.record_command_outcome(session_dir, enqueued_id, status:)
97
106
  id = enqueued_id.to_s
98
107
  return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
99
108
 
100
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
101
- FileUtils.mkdir_p(dir)
102
- record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
103
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
109
+ outcome = COMMAND_OUTCOMES.fetch(status.to_s, "canceled")
110
+ record = LastTurn.new(outcome: outcome, cancel_reason: (status.to_s if outcome == "canceled"),
111
+ ended_at: Time.now.iso8601(3), origin: COMMAND_ORIGIN, enqueued_id: id)
112
+ write_settled(session_dir, id, record)
104
113
  rescue SystemCallError, IOError => e
105
114
  Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
106
115
  end
@@ -109,14 +118,18 @@ module Samagotchi
109
118
  id = mark.enqueued_id.to_s
110
119
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
111
120
 
112
- dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
113
- FileUtils.mkdir_p(dir)
114
- record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
115
- AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
121
+ write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
116
122
  rescue SystemCallError => e
117
123
  # The settle goes on; a wait for that turn runs to its timeout.
118
124
  Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
119
125
  end
126
+
127
+ def self.write_settled(session_dir, id, record)
128
+ dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
129
+ FileUtils.mkdir_p(dir)
130
+ AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
131
+ end
132
+ private_class_method :write_settled
120
133
  private_class_method :record_settled
121
134
 
122
135
  # @param session [Session] the worker's session (saved as a whole)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.91.0"
4
+ VERSION = "0.92.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -35,10 +35,10 @@ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
35
35
  import { renderInfoBar } from "./info_bar.js";
36
36
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
37
37
  import { createShownTexts } from "./shown_texts.js";
38
- import { MODEL_KEY } from "./model_pick.js";
38
+ import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
39
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
40
  import { createModelPicker } from "./model_picker.js";
41
- import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { admitImages, chipName, chipsAfterSend, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
42
  import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
@@ -992,7 +992,10 @@ async function readTurnEnd() {
992
992
  lists.render();
993
993
  lists.paintActive();
994
994
  }
995
- } catch(_){}
995
+ } catch (_) {
996
+ // A failed read (the server or worker gone a moment) leaves the answer
997
+ // as the stream drew it, plain text: the session's next load renders it.
998
+ }
996
999
  }
997
1000
 
998
1001
  function seedSeenFromDom() {
@@ -1452,8 +1455,9 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1452
1455
  if (restored?.refill && sentTo === selected) openForVisit();
1453
1456
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1454
1457
  fitPrompt();
1455
- clearChips();
1456
- if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1458
+ // Only the sent chips go: images added since stay (they go up at their own send).
1459
+ const kept = chipsAfterSend(chips, sending);
1460
+ setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
1457
1461
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1458
1462
  // worker that wasn't live (no turn_enqueued then).
1459
1463
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
@@ -1548,7 +1552,7 @@ async function handleCancel() {
1548
1552
  $("#newBtn").addEventListener("click", () => {
1549
1553
  if (allViewOpen()) leaveAllView("");
1550
1554
  clearSelection();
1551
- loadModels();
1555
+ loadModels({ asked: true });
1552
1556
  });
1553
1557
 
1554
1558
  // ── The start page's model picker ──────────────────────────────────────────
@@ -1641,11 +1645,19 @@ function restoreStartPick() {
1641
1645
  renderNewChatLlmContext();
1642
1646
  renderNewChatThinking();
1643
1647
  }
1644
- async function loadModels() {
1648
+ // A reload cuts the fetch: its error is no news (modelsFailedText).
1649
+ let pageLeaving = false;
1650
+ addEventListener("beforeunload", () => { pageLeaving = true; });
1651
+ addEventListener("pagehide", () => { pageLeaving = true; });
1652
+ addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
1653
+ // +asked+: + new session's load, for the start page it opens.
1654
+ async function loadModels({ asked = false } = {}) {
1645
1655
  let payload;
1646
1656
  try {
1647
1657
  payload = await listModels();
1648
- } catch (_) {
1658
+ } catch (e) {
1659
+ const text = modelsFailedText(e, { startPage: !selected, asked, leaving: pageLeaving });
1660
+ if (text) showToast(text);
1649
1661
  return;
1650
1662
  }
1651
1663
  const stored = readStored(MODEL_KEY);
@@ -1678,16 +1690,13 @@ function renderChips() {
1678
1690
  drawFoldControls();
1679
1691
  }
1680
1692
 
1693
+ // A chip that goes frees its preview; one kept in +list+ keeps it.
1681
1694
  function setChips(list) {
1682
- for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
1695
+ for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
1683
1696
  chips = list;
1684
1697
  renderChips();
1685
1698
  }
1686
1699
 
1687
- function clearChips() {
1688
- setChips([]);
1689
- }
1690
-
1691
1700
  // A paste's, a drop's or the image button's files: the images the message
1692
1701
  // may still take become chips (images.js admitImages); a toast says why the
1693
1702
  // rest stayed out. Whether any went in.
@@ -1806,7 +1815,8 @@ composerEl.addEventListener("drop", (e) => {
1806
1815
  const unqueue = createUnqueue({
1807
1816
  sessionId: () => selected,
1808
1817
  clientId,
1809
- toast: (text) => showToast(text),
1818
+ toast: (text, action, options) => showToast(text, action, options),
1819
+ copy: (text) => copyText(text),
1810
1820
  // Edit: the removed text back into an empty composer (never over a draft).
1811
1821
  putBack: (text) => {
1812
1822
  if (promptEl.value.trim() || chips.length) return false;
@@ -150,7 +150,10 @@ export function createCardsPlace({
150
150
  const el = card.type === "card" && cardElement(card.id);
151
151
  if (el) refreshCardBody(el, card);
152
152
  }
153
- } catch (_) {}
153
+ } catch (_) {
154
+ // The live text stays (a failed re-read is only a lost render);
155
+ // the next card or load renders it.
156
+ }
154
157
  }, 150);
155
158
  }
156
159
 
@@ -94,6 +94,12 @@ export function chipsTemplate(chips) {
94
94
  html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
95
95
  }
96
96
 
97
+ // The chips left once a send is acked: those added while it was in flight
98
+ // (an upload can take seconds) stay for the next message.
99
+ export function chipsAfterSend(chips, sent) {
100
+ return (chips || []).filter((c) => !sent.includes(c));
101
+ }
102
+
97
103
  // The {file, name} refs a turn names.
98
104
  export function turnRefs(chips) {
99
105
  return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
@@ -1,7 +1,17 @@
1
1
  // The start page's model picker: pure helpers over GET /api/models' payload.
2
+ import { cantText } from "./format.js";
2
3
 
3
4
  export const MODEL_KEY = "chi_model";
4
5
 
6
+ // A failed GET /api/models' toast: the picker stays hidden, so it says why
7
+ // and what a new session gets (+ new session asks again). "" (no toast) in
8
+ // a session, where no picker shows (+asked+: + new session's own load),
9
+ // and for a fetch aborted or cut by the page unloading.
10
+ export function modelsFailedText(error, { startPage = true, asked = false, leaving = false } = {}) {
11
+ if (leaving || error?.name === "AbortError" || !(startPage || asked)) return "";
12
+ return `${cantText("list the models", error)}. A new session starts on the default model.`;
13
+ }
14
+
5
15
  // The name to preselect from the rows (modelRows): the remembered one when
6
16
  // it is still offered (any case), else the server's default, both even
7
17
  // when their host is down; else the first row whose host answered, else
@@ -36,19 +36,26 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
36
36
  entry.el);
37
37
  }
38
38
 
39
+ function item(text, action, sticky) {
40
+ const entry = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
+ entry.el.className = "toast-item";
42
+ draw(entry, text, action, sticky);
43
+ entry.key = entry.el.querySelector(".toast-message").textContent;
44
+ return entry;
45
+ }
46
+
39
47
  function show(text, action = null, { sticky = false } = {}) {
40
- const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
- candidate.el.className = "toast-item";
42
- draw(candidate, text, action, sticky);
43
- candidate.key = candidate.el.querySelector(".toast-message").textContent;
48
+ // Words are their own key; a template's key is its drawn text, so only
49
+ // a template is drawn before the look-up.
50
+ const candidate = typeof text === "string" ? null : item(text, action, sticky);
51
+ const key = candidate ? candidate.key : text;
44
52
 
45
- const duplicate = entries.find((entry) => entry.key === candidate.key);
53
+ const duplicate = entries.find((entry) => entry.key === key);
46
54
  if (duplicate) {
47
55
  if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
56
  duplicate.timer = null;
49
57
  duplicate.sticky = sticky || duplicate.sticky;
50
58
  duplicate.action = action || duplicate.action;
51
- duplicate.key = candidate.key;
52
59
  draw(duplicate, text, duplicate.action, duplicate.sticky);
53
60
  entries.push(...entries.splice(entries.indexOf(duplicate), 1));
54
61
  el.appendChild(duplicate.el);
@@ -60,9 +67,10 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
60
67
  if (entries.length === MAX_TOASTS) {
61
68
  remove(entries.find((entry) => !entry.sticky) || entries[0], false);
62
69
  }
63
- entries.push(candidate);
64
- el.appendChild(candidate.el);
65
- if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
70
+ const entry = candidate || item(text, action, sticky);
71
+ entries.push(entry);
72
+ el.appendChild(entry.el);
73
+ if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
66
74
  sync();
67
75
  }
68
76
 
@@ -4,8 +4,9 @@
4
4
  // goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
5
5
  // here at the 200 already. Its text comes back (edit): into an empty
6
6
  // composer, to change and send again; over a draft only in a toast (the
7
- // draft stays). Its images don't come back. A refusal keeps it and says why
8
- // in a toast. No confirm: nothing is lost. Pure but for the
7
+ // draft stays), and after a switch to another session meanwhile only in a
8
+ // sticky toast naming its session, with Copy. Its images don't come back. A refusal keeps it
9
+ // and says why in a toast. No confirm: nothing is lost. Pure but for the
9
10
  // injected calls, so `node --test` covers it; app.js binds onClick to the
10
11
  // turn roots.
11
12
  import { dequeueTurn as postDequeue } from "./data.js";
@@ -28,8 +29,9 @@ function clipped(text) {
28
29
 
29
30
  // +sessionId+ is read at each click (the open session); +clientId+ is this
30
31
  // tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
31
- // composer when it is empty and says whether it did.
32
- export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
32
+ // composer when it is empty and says whether it did; +copy(text)+ says
33
+ // whether the clipboard took it; +toast(text, action, options)+ as showToast.
34
+ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false, copy = null }) {
33
35
  // A click on a queued bubble's ×; any other click is left alone.
34
36
  async function onClick(e) {
35
37
  const button = e.target?.closest?.("button.unqueue");
@@ -44,6 +46,12 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
44
46
  const result = await dequeue(id, enqueuedId, { clientId });
45
47
  removeQueuedBubble(bubble);
46
48
  const text = String(result?.prompt || "");
49
+ // Another session opened meanwhile: its composer isn't where this
50
+ // text goes, so only the toast has it.
51
+ if (text.trim() && sessionId() !== id) {
52
+ movedOn(id, text);
53
+ return result;
54
+ }
47
55
  if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
48
56
  return result;
49
57
  } catch (error) {
@@ -52,5 +60,13 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
52
60
  return null;
53
61
  }
54
62
  }
63
+ // The removed text after a switch: in a sticky toast, its Copy takes the
64
+ // whole text (the toast clips it); a failed copy shows the toast again.
65
+ function movedOn(id, text) {
66
+ const line = `Removed from ${id.slice(0, 8)}: ${clipped(text)}`;
67
+ const show = () => toast(line, copy ? action : null, { sticky: true });
68
+ const action = { label: "Copy", run: async () => { if (await copy(text)) toast("Copied"); else show(); } };
69
+ show();
70
+ }
55
71
  return { onClick };
56
72
  }