samagotchi 0.16.0 → 0.17.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +13 -0
  5. data/docs/cli.md +4 -2
  6. data/docs/configuration.md +4 -4
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/memory.md +39 -0
  9. data/docs/plugins.md +2 -0
  10. data/docs/sessions.md +3 -2
  11. data/docs/sub-agent.md +1 -0
  12. data/docs/testing.md +47 -0
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  14. data/lib/samagotchi/bridge.rb +38 -7
  15. data/lib/samagotchi/bundle_command.rb +13 -2
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  22. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  23. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  24. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  26. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  27. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  28. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  29. data/lib/samagotchi/plugin/context.rb +14 -2
  30. data/lib/samagotchi/self_report.rb +49 -3
  31. data/lib/samagotchi/send_command.rb +45 -6
  32. data/lib/samagotchi/session_metrics.rb +137 -48
  33. data/lib/samagotchi/system_prompt.rb +45 -4
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  36. data/lib/samagotchi/terminal_ui.rb +24 -0
  37. data/lib/samagotchi/tools/builtins.rb +11 -3
  38. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  39. data/lib/samagotchi/tools/peers.rb +3 -2
  40. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +2 -1
  43. data/lib/samagotchi/web/public/app.js +37 -28
  44. data/lib/samagotchi/web/public/notify.js +7 -3
  45. data/lib/samagotchi/web/public/stage_view.js +13 -1
  46. data/lib/samagotchi/web/public/strip.js +11 -0
  47. data/lib/samagotchi/web/public/timing.js +26 -12
  48. data/lib/samagotchi/web/public/turn_events.js +13 -3
  49. data/lib/samagotchi/worker.rb +28 -2
  50. data/lib/samagotchi/worker_sidecar.rb +4 -1
  51. metadata +3 -1
@@ -105,12 +105,20 @@ module Samagotchi
105
105
  # What execute and task_create export into the commands they run: a
106
106
  # chi started there answers as a parent agent
107
107
  # (Guardrails::ParentApprovals.parent_process?), even under a PTY
108
- # wrapper. The session's id, "chi" without one.
108
+ # wrapper. The session's id, "chi" without one. And the model the
109
+ # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
110
+ # /model), which chi self reports; unset when unknown, so a value
111
+ # inherited from an outer session never passes for this one.
109
112
  def parent_env(kctx)
110
- id = kctx.peers.respond_to?(:session_id) ? kctx.peers.session_id.to_s : ""
111
- { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id }
113
+ peers = kctx.peers
114
+ id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
115
+ model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
116
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
117
+ SESSION_MODEL_ENV => model.empty? ? nil : model }
112
118
  end
113
119
 
120
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
121
+
114
122
  # Stop flips the turn's controller, seen through the Engine's PeerView
115
123
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
116
124
  def cancelled_proc(kctx)
@@ -55,6 +55,10 @@ module Samagotchi
55
55
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
56
56
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
57
57
  settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
58
+ ensure
59
+ # A relay that failed before it settled closes, so the desk (which
60
+ # prunes only settled ones) keeps none open; a settled one stays.
61
+ desk&.close(relay_id) if relay_id
58
62
  end
59
63
 
60
64
  # Closes the card when the child's question is no longer pending (it
@@ -7,10 +7,11 @@ module Samagotchi
7
7
  # folder, where sessions live, and whether the running turn was
8
8
  # canceled (a waiting tool returns on it), and the question flow a
9
9
  # delegate's approval is relayed through (relay: nil when this session
10
- # can't host one). Engine hands KernelLoop one that follows its current
10
+ # can't host one), and the model the session runs on (model_ref: the
11
+ # resolved ref, live after /model). Engine hands KernelLoop one that follows its current
11
12
  # session (Engine::PeerView); this plain one is for specs and a kernel
12
13
  # without an Engine.
13
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, keyword_init: true) do
14
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
14
15
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
15
16
  def cancelled?
16
17
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
21
21
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
22
22
  # Set by chi (Builtins.parent_env); the model's env can't change it.
23
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
23
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
24
24
 
25
25
  module_function
26
26
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.16.0"
4
+ VERSION = "0.17.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1059,7 +1059,8 @@ module Samagotchi
1059
1059
  port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
1060
1060
  end
1061
1061
 
1062
- # Read the live Engine state over the bridge (raw GET /session/:id/state).
1062
+ # GET a read path of the session's live Bridge (+path+: "state",
1063
+ # "snapshot", "stats", ...).
1063
1064
  # @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
1064
1065
  def bridge_get_json(session_id, path)
1065
1066
  bridge_client(session_id)&.get_json(path)
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  listSessions,
3
- createSession,
4
3
  getSession,
5
4
  sendTurn,
6
5
  cancelTurn,
@@ -37,14 +36,14 @@ import { createPromptHistory } from "./prompt_history.js";
37
36
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
37
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
38
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
- import { stripColumns, stripShowParts } from "./strip.js";
39
+ import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
41
40
  import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
41
  import { createNotifications } from "./notifications.js";
43
42
  import {
44
43
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
44
  } from "./timing.js";
46
45
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
46
+ import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
47
 
49
48
  const $ = (s) => document.querySelector(s);
50
49
  const topStripEl = $("#topStrip");
@@ -1732,37 +1731,19 @@ function addCreatedSession(created) {
1732
1731
  render();
1733
1732
  }
1734
1733
 
1735
- // A first message: an idle session, then the turn into it as for any
1736
- // later one (handleSendTurn), so a failed first turn's prompt comes back
1737
- // into the composer too (sentIds). A first command line starts the session
1738
- // with it.
1739
- async function handleCreate() {
1740
- const prompt = promptEl.value.trim();
1741
- if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
1742
- if (!prompt) return;
1743
- actionBtn.disabled = true;
1744
- try {
1745
- const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
1746
- addCreatedSession(s);
1747
- select(s.id);
1748
- promptEl.value = "";
1749
- fitPrompt();
1750
- refreshHistory();
1751
- } catch (e) {
1752
- alert(e.message);
1753
- } finally {
1754
- actionBtn.disabled = false;
1755
- }
1734
+ // A first message: an idle session, then the turn (or the command) into it
1735
+ // as for any later one (handleSendTurn), so a failed first turn's prompt
1736
+ // comes back into the composer too (sentIds).
1737
+ function handleCreate() {
1738
+ return handleSendTurn();
1756
1739
  }
1757
1740
 
1758
1741
  async function handleSendTurn() {
1759
- if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
1760
1742
  if (current.owner === "tui") return;
1761
1743
  const prompt = turnText(promptEl.value, chips);
1762
1744
  if (!prompt) return;
1763
1745
  // An image-only send's text is its placeholders: not for ↑.
1764
1746
  const typed = promptEl.value.trim() !== "";
1765
- if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
1766
1747
  if (!selected) {
1767
1748
  actionBtn.disabled = true;
1768
1749
  try {
@@ -1776,6 +1757,9 @@ async function handleSendTurn() {
1776
1757
  actionBtn.disabled = false;
1777
1758
  }
1778
1759
  }
1760
+ // A session command by the session's list (the new one's too: select
1761
+ // read it); an unknown /word is a prompt, as in a terminal.
1762
+ if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
1779
1763
  const hint = historyEl.querySelector(".hint");
1780
1764
  if (hint) hint.remove();
1781
1765
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -1795,6 +1779,15 @@ async function handleSendTurn() {
1795
1779
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1796
1780
  }
1797
1781
  const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
1782
+ if (ack?.command_id) {
1783
+ // The worker knew it as a command (its list was newer than ours):
1784
+ // its command_ran shows it, not a prompt bubble.
1785
+ echo.remove();
1786
+ promptEl.value = "";
1787
+ fitPrompt();
1788
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1789
+ return;
1790
+ }
1798
1791
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1799
1792
  refreshHistory();
1800
1793
  // A turn that failed fast was restored before this ack: its prompt
@@ -2247,11 +2240,27 @@ function stripHiddenSaved() {
2247
2240
  return false;
2248
2241
  }
2249
2242
  }
2243
+ // A short window hides the strip by itself, so the stage keeps room for a
2244
+ // card that waits (its question and choices); the sessions pill brings it
2245
+ // back until the window is tall again. Not saved: the saved choice is the
2246
+ // user's own hide ▴ / show.
2247
+ const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2248
+ let stripShownWhileShort = false;
2249
+ function applyStripHidden() {
2250
+ document.body.classList.toggle("strip-hidden", stripAutoHidden({
2251
+ saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2252
+ }));
2253
+ }
2250
2254
  function setStripHidden(hidden) {
2251
2255
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2252
- document.body.classList.toggle("strip-hidden", hidden);
2256
+ stripShownWhileShort = !hidden && shortWindow.matches;
2257
+ applyStripHidden();
2253
2258
  }
2254
- setStripHidden(stripHiddenSaved());
2259
+ applyStripHidden();
2260
+ shortWindow.addEventListener("change", () => {
2261
+ if (!shortWindow.matches) stripShownWhileShort = false;
2262
+ applyStripHidden();
2263
+ });
2255
2264
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2256
2265
 
2257
2266
  $("#filter").addEventListener("input", renderAll);
@@ -31,9 +31,13 @@ export function waitingOn(summary) {
31
31
  }
32
32
 
33
33
  // How long a delegated child's new approval waits before it notifies: its
34
- // parent relays it within a poll or two when it waits on the child, and
35
- // then the parent's card notifies instead (one bell, not two).
36
- export const RELAY_HOLD_MS = 1500;
34
+ // parent relays it when it waits on its children, and then the parent's
35
+ // card notifies instead (one bell, not two). A child the parent isn't
36
+ // waiting on directly is seen by its look at the others (DelegateRelay
37
+ // OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
38
+ // scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
39
+ // wait; a child's approval nobody relays rings this late.
40
+ export const RELAY_HOLD_MS = 3500;
37
41
 
38
42
  // What changed between two summaries of one session that needs the user.
39
43
  // @param prev the summary held before (null for a session not seen yet)
@@ -360,6 +360,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
360
360
  // never above the session panel (the dock is absolute: a tall composer or
361
361
  // a phone's keyboard would push the stage's top out) ────────────────────
362
362
 
363
+ // Scroll the stage so +slot+'s end shows (it is the scroller's child).
364
+ function revealSlot(slot) {
365
+ if (stage.hidden || slot.hidden) return;
366
+ const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
367
+ if (end > scroller.scrollTop) scroller.scrollTop = end;
368
+ }
369
+
363
370
  function fitCap() {
364
371
  if (stage.hidden) return;
365
372
  const rest = dockEl.offsetHeight - stage.offsetHeight;
@@ -593,8 +600,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
593
600
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
594
601
  slot.appendChild(el);
595
602
  slot.hidden = false;
596
- if (stick || el.matches?.(PENDING) || el.querySelector?.(ASKS)) slot.scrollTop = slot.scrollHeight;
603
+ const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
604
+ if (stick || waits) slot.scrollTop = slot.scrollHeight;
597
605
  scheduleRefresh();
606
+ // A card that waits for the user is brought into the stage's view: in
607
+ // a short window the prompt and headline fill it and the card sat
608
+ // below, out of sight (after the refresh, which hides the live slots).
609
+ if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
598
610
  return true;
599
611
  },
600
612
  // Where the live timing line goes (the status row), null: the history.
@@ -30,3 +30,14 @@ export function stripShowParts(sessions) {
30
30
  waiting: waitingN ? `${waitingN} waiting` : "",
31
31
  };
32
32
  }
33
+
34
+ // A window this short hides the strip by itself: at ~500-700 px the stage
35
+ // is capped low and a card that waits there (a question, an approval) is
36
+ // cut off below the strip.
37
+ export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
38
+
39
+ // Whether the strip is hidden: the user's own hide (saved), or a short
40
+ // window unless the user showed it (the pill) since it got short.
41
+ export function stripAutoHidden({ saved, short, shownWhileShort }) {
42
+ return !!saved || (!!short && !shownWhileShort);
43
+ }
@@ -185,37 +185,51 @@ function recordRows(tools, iteration) {
185
185
  }
186
186
 
187
187
  // A turn whose saved messages carry their parts (the server's ?parts=1):
188
- // every assistant message is one generation, so the k-th is iteration k
189
- // and its calls pair with that iteration's records by position. The last
190
- // message is the answer when it has text and made no call (and the turn
191
- // is no canceled multi-step one); its thinking stays behind as a step, as
192
- // it does live.
188
+ // every assistant message is one step. Its calls take the turn's records by
189
+ // tool, in call order (each the next record of its tool, consumed with the
190
+ // ones it skipped), not by iteration: an empty retry spends an iteration
191
+ // that left no message (as the TUI's join, attached_loop.rb join_duration).
192
+ // The last message is the answer when it has text and made no call (and the
193
+ // turn is no canceled multi-step one); its thinking stays behind as a step,
194
+ // as it does live.
193
195
  function partsTurn(g, items, record, tools, stepsOnly = false) {
194
196
  const last = g.texts[g.texts.length - 1];
195
197
  const lastItem = items[last];
196
198
  const answer = !stepsOnly && String(lastItem.content ?? "") !== "" && !lastItem.parts?.tools?.length ? last : null;
199
+ const pool = [...tools].sort((a, b) =>
200
+ (Number(a.iteration) || 0) - (Number(b.iteration) || 0) || (Number(a.call_index) || 0) - (Number(b.call_index) || 0));
197
201
  const steps = g.texts.map((i, k) => {
198
202
  const iteration = k + 1;
199
203
  const parts = items[i].parts || {};
200
- const records = recordRows(tools, iteration);
201
204
  const rows = Array.isArray(parts.tools)
202
205
  ? parts.tools.map((call, j) => {
203
- const r = records.find((row) => row.key === `${iteration}:${j + 1}`);
206
+ const r = takeRecord(pool, call.tool);
204
207
  return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
205
208
  })
206
- : records;
209
+ : [];
207
210
  const thinking = String(parts.thinking || "");
208
211
  if (i === answer) return thinking ? { i: null, iteration, thinking, tools: [] } : null;
209
212
  return { i, iteration, thinking, tools: rows };
210
213
  }).filter(Boolean);
211
- // Records past the saved messages (a generation that saved nothing).
212
- const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
213
- for (let iteration = g.texts.length + 1; iteration <= maxIteration; iteration += 1) {
214
- steps.push({ i: null, iteration, thinking: "", tools: recordRows(tools, iteration) });
214
+ // Records no saved message took (a generation that saved nothing): a
215
+ // step per iteration of theirs, after the messages'.
216
+ let iteration = g.texts.length;
217
+ for (const n of [...new Set(pool.map((r) => Number(r.iteration) || 0))]) {
218
+ iteration += 1;
219
+ const rows = recordRows(pool, n).map((row) => ({ ...row, key: `${iteration}:${row.key.split(":")[1]}` }));
220
+ steps.push({ i: null, iteration, thinking: "", tools: rows });
215
221
  }
216
222
  return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
217
223
  }
218
224
 
225
+ // The next record of +tool+ in +pool+ (call order), removed with the ones
226
+ // before it; undefined when there is none (the pool stays as it was).
227
+ function takeRecord(pool, tool) {
228
+ const at = pool.findIndex((r) => String(r.tool) === String(tool));
229
+ if (at < 0) return undefined;
230
+ return pool.splice(0, at + 1)[at];
231
+ }
232
+
219
233
  // Append +el+ to the history, above +timingEl+ while it is a running turn's
220
234
  // live timing line, so that line stays under the turn's activity, thinking
221
235
  // and answer.
@@ -97,9 +97,19 @@ export function initWaitLine({ tasks } = {}) {
97
97
  return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
98
98
  }
99
99
 
100
- // A composer line for the worker's command route rather than a prompt.
101
- export function isCommandLine(text) {
102
- return /^[/!]/.test(String(text || "").trim());
100
+ // Whether a composer line in an open session is one of its commands
101
+ // (+commands+: the session GET's listing), as the terminals decide
102
+ // (Commands::Registry#lookup): a "!" line with something after the "!", or
103
+ // a listed name alone or followed by a space; a terminal's own (/stats,
104
+ // /exit) too, which the page answers itself (webLocalReply). An unknown
105
+ // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
106
+ export function sessionCommandLine(text, commands) {
107
+ const line = String(text || "").trim();
108
+ if (/^!\s*\S/.test(line)) return true;
109
+ if (!line.startsWith("/")) return false;
110
+ if (webLocalReply(line)) return true;
111
+ return (Array.isArray(commands) ? commands : [])
112
+ .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
103
113
  }
104
114
 
105
115
  // The terminal's own commands (a button in the web, or none yet): the
@@ -173,7 +173,7 @@ module Samagotchi
173
173
  absorb_notes
174
174
 
175
175
  if (prompt = take_initial_prompt)
176
- run_prompt(prompt, nil)
176
+ run_prompt(prompt, nil) unless initial_command(prompt)
177
177
  next
178
178
  end
179
179
 
@@ -269,6 +269,29 @@ module Samagotchi
269
269
  prompt
270
270
  end
271
271
 
272
+ # A session command sent as a message (chi send -m "/model x", a web
273
+ # page's first message) runs as the command, as the Bridge does for a
274
+ # POST /turn; an unknown /word stays a prompt. Queued: the loop's next
275
+ # pass runs it.
276
+ # @return [Boolean] whether +text+ was one
277
+ def queue_as_command(text, origin)
278
+ return false unless @engine.command_registry.command?(text.to_s)
279
+
280
+ @bridge.queue_command(text, client_id: origin&.dig(:client_id))
281
+ true
282
+ end
283
+
284
+ # The first prompt as a command: the session was saved as running for a
285
+ # turn that won't run.
286
+ # @return [Boolean] whether it was one
287
+ def initial_command(prompt)
288
+ return false unless queue_as_command(prompt, nil)
289
+
290
+ @session.status = Session::STATUS_IDLE
291
+ save_session
292
+ true
293
+ end
294
+
272
295
  # Add the queued context notes to the conversation (between turns only,
273
296
  # on this thread), save, then delete their files: a crash before the
274
297
  # delete leaves them claimed, and Engine#add_context_note skips a note
@@ -293,7 +316,10 @@ module Samagotchi
293
316
 
294
317
  begin
295
318
  message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
296
- run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || []) unless message.to_s.strip.empty?
319
+ return if message.to_s.strip.empty?
320
+ return if Array(images).empty? && queue_as_command(message, origin)
321
+
322
+ run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
297
323
  ensure
298
324
  FileUtils.rm_f(claimed_file)
299
325
  end
@@ -42,7 +42,10 @@ module Samagotchi
42
42
  return nil unless sidecar&.port&.positive?
43
43
 
44
44
  begin
45
- Socket.tcp(host, sidecar.port, connect_timeout: timeout).close
45
+ # Not Socket.tcp: with connect_timeout (Ruby 3.4, macOS 26) it hands
46
+ # back the socket of a refused connect (SO_ERROR set), so a dead
47
+ # worker's port read as live. TCPSocket raises ECONNREFUSED.
48
+ TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
46
49
  sidecar
47
50
  rescue StandardError
48
51
  FileUtils.rm_f(path(session_dir)) if unlink
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.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -137,6 +137,7 @@ files:
137
137
  - docs/releasing.md
138
138
  - docs/sessions.md
139
139
  - docs/sub-agent.md
140
+ - docs/testing.md
140
141
  - lib/samagotchi.rb
141
142
  - lib/samagotchi/answer_command.rb
142
143
  - lib/samagotchi/answer_display.rb
@@ -280,6 +281,7 @@ files:
280
281
  - lib/samagotchi/memory_bundle/source.rb
281
282
  - lib/samagotchi/memory_bundle/status.rb
282
283
  - lib/samagotchi/memory_bundle/system_bundle.rb
284
+ - lib/samagotchi/memory_bundle/trash.rb
283
285
  - lib/samagotchi/memory_bundle/uninstaller.rb
284
286
  - lib/samagotchi/memory_paths.rb
285
287
  - lib/samagotchi/model_catalog.rb