samagotchi 0.32.0 → 0.34.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. metadata +14 -1
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../process_group"
9
10
  require_relative "shell"
10
11
 
11
12
  module Samagotchi
@@ -24,6 +25,9 @@ module Samagotchi
24
25
  MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
26
  # task_wait and task_get add it to a task the user stopped (the web's
26
27
  # stop-task button), so the model doesn't rerun the job.
28
+ # A record whose pid isn't a process group chi started (missing, 0, 1,
29
+ # not a number, or a pid another process reuses): nothing is signalled.
30
+ NOT_CHIS_PROCESS = "the task's process is gone or isn't the one chi started"
27
31
  USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
28
32
 
29
33
  module_function
@@ -54,13 +58,12 @@ module Samagotchi
54
58
 
55
59
  wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
56
60
  # Non-login, the shell Execute uses too (Shell).
57
- pid = Process.spawn(
61
+ pid = ProcessGroup.spawn(
58
62
  spawn_env,
59
63
  *Shell.argv(wrapped_command),
60
64
  chdir: resolved_cwd,
61
65
  out: output_io,
62
- err: output_io,
63
- pgroup: true
66
+ err: output_io
64
67
  )
65
68
  Process.detach(pid)
66
69
 
@@ -185,26 +188,15 @@ module Samagotchi
185
188
  refreshed = refresh_record(record)
186
189
  return [refreshed, nil] unless refreshed["status"] == "running"
187
190
 
191
+ pid = refreshed["pid"]
192
+ # Checked again just before the signal: never a group chi didn't start.
193
+ return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
194
+
188
195
  refreshed["stop_requested_by"] = by.to_s
189
196
  write_record(refreshed)
190
- pid = refreshed["pid"].to_i
191
-
192
- begin
193
- Process.kill("TERM", -pid)
194
- rescue Errno::ESRCH
195
- # Process already exited; status refresh below handles final state.
196
- end
197
197
 
198
- deadline = monotonic_time + STOP_GRACE_SEC
199
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
200
-
201
- if process_alive?(pid)
202
- begin
203
- Process.kill("KILL", -pid)
204
- rescue Errno::ESRCH
205
- # Process exited between checks.
206
- end
207
- end
198
+ # A group that isn't ours (EPERM) is an error, not a stop.
199
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
208
200
 
209
201
  updated = mark_stopped(refresh_record(refreshed))
210
202
  write_record(updated)
@@ -217,10 +209,18 @@ module Samagotchi
217
209
  def refresh_record(record)
218
210
  return record unless record["status"] == "running"
219
211
 
220
- pid = record["pid"].to_i
221
- return mark_finished_without_exit_code(record) unless process_alive?(pid)
212
+ pid = record["pid"]
213
+ return record if ProcessGroup.leader?(pid)
214
+ # Gone: its exit code says how it ended.
215
+ return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
222
216
 
223
- record
217
+ mark_not_chis(record)
218
+ end
219
+
220
+ # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
+ # ends as failed, unless its exit code or a stop on disk says more.
222
+ def mark_not_chis(record)
223
+ mark_finished_without_exit_code(record, missing_reason: NOT_CHIS_PROCESS)
224
224
  end
225
225
 
226
226
  # A session id (a uuid) is never a task's: task ids are a timestamp
@@ -338,16 +338,7 @@ module Samagotchi
338
338
  filtered.join(" ")
339
339
  end
340
340
 
341
- def process_alive?(pid)
342
- Process.kill(0, pid)
343
- true
344
- rescue Errno::ESRCH
345
- false
346
- rescue Errno::EPERM
347
- true
348
- end
349
-
350
- def mark_finished_without_exit_code(record)
341
+ def mark_finished_without_exit_code(record, missing_reason: "process_ended_without_exit_code")
351
342
  record = current_record(record)
352
343
  return record unless record["status"] == "running"
353
344
 
@@ -359,7 +350,7 @@ module Samagotchi
359
350
  elsif exit_code.nil?
360
351
  record["status"] = "failed"
361
352
  record["exit_code"] = nil
362
- record["stop_reason"] ||= "process_ended_without_exit_code"
353
+ record["stop_reason"] ||= missing_reason
363
354
  elsif exit_code.zero?
364
355
  record["status"] = "completed"
365
356
  record["exit_code"] = 0
@@ -401,10 +392,6 @@ module Samagotchi
401
392
  def timestamp
402
393
  Time.now.utc.iso8601
403
394
  end
404
-
405
- def monotonic_time
406
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
407
- end
408
395
  end
409
396
  end
410
397
  end
@@ -36,8 +36,16 @@ module Samagotchi
36
36
  # @param restored [Boolean] the prompt went back to its sender (the
37
37
  # failed user message is not in the conversation any more)
38
38
  # @param continued [Boolean] it was a continue turn (no user message)
39
- def failed(summary, restored: false, continued: false)
39
+ # @param wake [String, nil] what woke it, when chi started the turn and
40
+ # not the user ("the change in attached context pr-7"): the user
41
+ # wrote nothing, and wakes pause until they do
42
+ # @param kept [String, nil] what becomes of the wake's news, after
43
+ # that (a delegate report's rings stay for the user's next turn)
44
+ def failed(summary, restored: false, continued: false, wake: nil, kept: nil)
40
45
  tail = if restored then RESTORED
46
+ elsif wake
47
+ "The wake turn for #{one_line(wake)} was not answered; chi starts no other wake turn until the user writes." +
48
+ (kept ? " #{one_line(kept)}" : "")
41
49
  elsif continued then "The continued turn stopped there."
42
50
  else "The user's last message was not answered."
43
51
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.32.0"
4
+ VERSION = "0.34.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -9,6 +9,7 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../client_id"
12
13
  require_relative "../answer_tail"
13
14
  require_relative "../bridge_client"
14
15
  require_relative "../bridge/bounded_queue"
@@ -1069,7 +1070,7 @@ module Samagotchi
1069
1070
  # {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
1070
1071
  # owned_by_tui; 404.
1071
1072
  def handle_restart(_req, id)
1072
- result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
1073
+ result = @manager.restart_session(id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
1073
1074
  @hub&.touch(id)
1074
1075
  json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
1075
1076
  version: result.version })
@@ -21,6 +21,8 @@ import {
21
21
  addContext,
22
22
  } from "./data.js";
23
23
  import { createContextChips } from "./context_chips.js";
24
+ import { renderStable } from "./stable_html.js";
25
+ import { createShownTexts } from "./shown_texts.js";
24
26
  import { MODEL_KEY } from "./model_pick.js";
25
27
  import { emptySessionState } from "./session_state.js";
26
28
  import { createModelPicker } from "./model_picker.js";
@@ -63,7 +65,7 @@ const infoBarEl = $("#infoBar");
63
65
  const firstPeekEl = $("#firstPeek");
64
66
  const infoTextEl = $("#infoText");
65
67
  // The open session's attached context, in its own row of the session bar
66
- // (updateInfoBar rewrites #infoText every tick; the chips keep their popover).
68
+ // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
67
69
  const contextChips = createContextChips({
68
70
  el: $("#contextChips"),
69
71
  api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
@@ -87,7 +89,8 @@ let eventsControl = null;
87
89
  let resyncsInFlight = 0;
88
90
  let resyncAgain = false;
89
91
  let allSessions = [];
90
- let seenContent = new Set();
92
+ // The running turn's texts on the page (the turn output dedupe).
93
+ const shownTexts = createShownTexts();
91
94
  // The page's view of a turn (web.view, or ?view=stage|turn): the running
92
95
  // turn pinned above the composer (the stage, stage_view.js), or one block
93
96
  // per turn with its steps in the history (turn_view.js).
@@ -98,7 +101,7 @@ const viewArgs = {
98
101
  historyEl,
99
102
  appendToHistory: (el) => appendToHistory(el),
100
103
  isNearBottom: () => isNearBottom(),
101
- sawText: (k) => seenContent.add(k),
104
+ sawText: (k) => shownTexts.saw(k),
102
105
  sessionId: () => selected,
103
106
  // The stop-task button on a running task_wait, for a worker that has
104
107
  // the route (its sidecar features).
@@ -950,11 +953,7 @@ function applyFinalMarkdown(messages) {
950
953
  }
951
954
 
952
955
  function seedSeenFromDom() {
953
- seenContent = new Set();
954
- findInTurnRoots(".bubble").forEach((b) => {
955
- const k = normalize(b.textContent);
956
- if (k) seenContent.add(k);
957
- });
956
+ shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
958
957
  }
959
958
 
960
959
  // Sticky-follow scroll: only auto-scroll to the bottom while the user is
@@ -974,7 +973,7 @@ function atHistoryEnd() {
974
973
  function appendChunk(text) {
975
974
  if (!text) return;
976
975
  const k = normalize(text);
977
- if (!k || seenContent.has(k)) return;
976
+ if (!k || shownTexts.shows(k)) return;
978
977
  const follow = isNearBottom();
979
978
  const hint = historyEl.querySelector(".hint");
980
979
  if (hint) hint.remove();
@@ -1464,7 +1463,7 @@ function updateInfoBar() {
1464
1463
  }
1465
1464
  if (!selected) {
1466
1465
  // The start page hides the info bar (the storage path is on #/sessions).
1467
- infoTextEl.innerHTML = "";
1466
+ renderStable(infoTextEl, "");
1468
1467
  infoTextEl.title = "";
1469
1468
  if (stopBtn) stopBtn.style.display = "none";
1470
1469
  return;
@@ -1488,9 +1487,11 @@ function updateInfoBar() {
1488
1487
  const attachCmd = `chi --attach ${selected}`;
1489
1488
  const copied = Date.now() - attachCopiedAt < 2000;
1490
1489
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1491
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1490
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1492
1491
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1493
- infoTextEl.innerHTML = metaHtml + previewHtml;
1492
+ // The session clock ticks every second while a turn runs: only its text
1493
+ // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1494
+ renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
1494
1495
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
1495
1496
  }
1496
1497
 
@@ -1759,6 +1760,7 @@ const streamHandlers = {
1759
1760
  // for a queued prompt among its rows.
1760
1761
  turnDom.handOff?.({ immediate: true });
1761
1762
  markRecapStale();
1763
+ shownTexts.turnStarted();
1762
1764
  const promptBubble = applyPromptOps(data);
1763
1765
  setTurnRunning(true);
1764
1766
  setLiveStatus("running");
@@ -2071,12 +2073,9 @@ async function handleSendTurn() {
2071
2073
  // for its ack is never taken back (4.13), so drop it here. The prompt
2072
2074
  // is still in the composer, as the echo was removed.
2073
2075
  dropEarlyRestores(earlyRestores);
2074
- // The chips stay (uploaded ones keep their ref) for another try.
2075
- if (/bad_image|too_large/.test(e.message)) {
2076
- alert(e.message);
2077
- return;
2078
- }
2079
- if (/owned_by_tui|409/.test(e.message)) {
2076
+ // The chips stay (uploaded ones keep their ref) for another try: an
2077
+ // image refused (bad_image, too_large, bad_images) says why below.
2078
+ if (e.code === "owned_by_tui") {
2080
2079
  current.owner = "tui";
2081
2080
  updateComposerMode();
2082
2081
  return;
@@ -2107,7 +2106,7 @@ async function handleSendCommand(line) {
2107
2106
  // The command woke the worker: follow its stream if none is open.
2108
2107
  followWokenWorker();
2109
2108
  } catch (e) {
2110
- if (/owned_by_tui|409/.test(e.message)) {
2109
+ if (e.code === "owned_by_tui") {
2111
2110
  current.owner = "tui";
2112
2111
  updateComposerMode();
2113
2112
  return;
@@ -2168,7 +2167,7 @@ async function handleCancel() {
2168
2167
  await cancelTurn(selected);
2169
2168
  } catch (e) {
2170
2169
  // The turn ended meanwhile: nothing left to cancel.
2171
- if (!/not_running|409/.test(e.message)) alert(e.message);
2170
+ if (e.code !== "not_running") alert(e.message);
2172
2171
  } finally {
2173
2172
  cancelBtn.disabled = false;
2174
2173
  }
@@ -8,6 +8,9 @@ export function api(path, opts = {}) {
8
8
  if (!r.ok) {
9
9
  const error = new Error(`${j.detail || j.error || r.statusText} (${r.status})`);
10
10
  error.status = r.status;
11
+ // The server's error code ({error: "owned_by_tui", detail}): what
12
+ // the page matches on, never the message's words.
13
+ error.code = j.error || null;
11
14
  throw error;
12
15
  }
13
16
  return j;
@@ -163,14 +163,13 @@ export function approvalAllowed(pq, answer) {
163
163
  }
164
164
 
165
165
  // What the card says when an answer or a dismissal didn't go through. 503
166
- // (the server found no live bridge) reads as the session not running; any
167
- // other failure — the worker's own (the server maps it to a 502
166
+ // not_live (the server found no live bridge) reads as the session not
167
+ // running; any other failure — the worker's own (the server maps it to a 502
168
168
  // bridge_error) or the connection's — shows what it said, so a worker that
169
169
  // failed the request is not mistaken for one that is gone.
170
170
  export function answerErrorText(error) {
171
- const status = error?.status;
172
171
  const message = String(error?.message ?? error ?? "");
173
- if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
172
+ if (error?.status === 503 || error?.code === "not_live") return "Session is not running — restart it to answer.";
174
173
  return message;
175
174
  }
176
175
 
@@ -0,0 +1,13 @@
1
+ // The texts the running turn already shows (its generations as they close,
2
+ // the bubbles a render drew): turn_completed's answer gets a bubble of its
3
+ // own only when it isn't one of them (it wasn't streamed). A new turn starts
4
+ // over, so an earlier turn's "Done." doesn't hide this one's.
5
+ export function createShownTexts() {
6
+ let seen = new Set();
7
+ return {
8
+ turnStarted() { seen = new Set(); },
9
+ seed(texts) { seen = new Set(texts.filter(Boolean)); },
10
+ saw(text) { if (text) seen.add(text); },
11
+ shows(text) { return seen.has(text); },
12
+ };
13
+ }
@@ -0,0 +1,18 @@
1
+ // A block redrawn every tick (the info bar, once a second while a turn
2
+ // runs) keeps its nodes: +html+ is written only when it changed, so a
3
+ // shown tooltip stays up and a press on one of its buttons isn't lost to a
4
+ // rewrite between mousedown and mouseup. +live+ ({selector: text}) names
5
+ // the parts that change every tick (a running clock): +html+ carries them
6
+ // empty and only their text is set, so a tick alone rewrites nothing else.
7
+ const shown = new WeakMap();
8
+
9
+ export function renderStable(el, html, live = {}) {
10
+ if (shown.get(el) !== html) {
11
+ el.innerHTML = html;
12
+ shown.set(el, html);
13
+ }
14
+ for (const [selector, text] of Object.entries(live)) {
15
+ const part = el.querySelector(selector);
16
+ if (part && part.textContent !== text) part.textContent = text;
17
+ }
18
+ }
@@ -93,7 +93,13 @@ export function isOwn(clientId, myId) {
93
93
 
94
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
95
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
96
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
96
+ // By the whole id or its prefix (up to and with its first ":"); an id
97
+ // chi doesn't know is "automatic", not the user (ClientId.human?).
98
+ const CLIENT_LABELS = Object.freeze({
99
+ "tui:": "tui", "web:": "web", "system:": "reminder", "delegate:": "delegate", "child:": "delegate report",
100
+ "cli:send": "chi send", "cli:answer": "chi answer", plugin: "plugin",
101
+ });
102
+ const AUTOMATIC_LABEL = "automatic";
97
103
 
98
104
  // A delegate child's report comes in as client child:<id8> (ChildReports).
99
105
  const REPORT_CLIENT = "child:";
@@ -110,7 +116,8 @@ export function clientLabel(clientId) {
110
116
  if (!clientId) return null;
111
117
  const id = String(clientId);
112
118
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
113
- return CLIENT_LABELS[id.split(":", 1)[0]] || "user";
119
+ const colon = id.indexOf(":");
120
+ return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
114
121
  }
115
122
 
116
123
  // The note for reminders a turn got (a reminder turn has no prompt).
@@ -390,7 +390,7 @@ export function createTurnView({
390
390
  }
391
391
  writeClosedLabel(entry, gen);
392
392
  if (!entry.toggled) entry.el.open = false;
393
- // Every generation's text goes into seenContent (the turn output dedupe).
393
+ // Every generation's text goes into the shown texts (the turn output dedupe).
394
394
  const k = normalize(gen.text);
395
395
  if (k) sawText(k);
396
396
  }
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
  require "time"
6
6
 
7
+ require_relative "client_id"
7
8
  require_relative "config"
8
9
  require_relative "events"
9
10
  require_relative "session"
@@ -287,6 +288,9 @@ module Samagotchi
287
288
  @context_poller&.stop
288
289
  @engine&.shutdown
289
290
  @bridge&.stop
291
+ # The process exits next: a parent wake it rang for (a crash's
292
+ # ring comes just above) must spawn the parent's worker first.
293
+ ChildRing.await_wakes
290
294
  end
291
295
  end
292
296
 
@@ -425,7 +429,7 @@ module Samagotchi
425
429
  @context_absorber.commit(waking ? woken(batch, waking) : batch)
426
430
  return false unless waking
427
431
 
428
- run_context_wake_turn(waking.name, turn_id)
432
+ run_context_wake_turn(waking, turn_id)
429
433
  true
430
434
  rescue SystemCallError, IOError => e
431
435
  Log.exception(:worker, "context_failed", e)
@@ -473,17 +477,21 @@ module Samagotchi
473
477
  # turn from context:<name> whose first message is the wake note already
474
478
  # in the conversation (named by +turn_id+), on the same rules as a
475
479
  # delegate report's wake turn: it counts toward session.max_wakes, and
476
- # a failure goes back to before the turn (the note stays: it was saved
477
- # before the checkpoint) and pauses wakes until a human's input.
478
- def run_context_wake_turn(name, turn_id)
480
+ # a failure goes back to before the turn (the note stays, as a plain
481
+ # one: #unmark_wake_note) and pauses wakes until a human's input.
482
+ # +waking+: the ContextAbsorber::Delivery that woke it.
483
+ def run_context_wake_turn(waking, turn_id)
484
+ name = waking.name
479
485
  @wakes_in_a_row += 1
480
486
  Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
481
487
  @turn_flow.before_prompt_turn
482
- run_engine_turn(nil, continue: true, origin: { client_id: "#{Steer::CONTEXT_CLIENT_PREFIX}#{name}" },
488
+ run_engine_turn(nil, continue: true, origin: { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" },
483
489
  id: turn_id, max_iterations: IterationLimit.for) do |result, error|
484
490
  if error
485
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
491
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
492
+ wake: "the change in attached context #{name}")
486
493
  @turn_flow.prompt_turn_failed(note: note)
494
+ unmark_wake_note(turn_id, waking.note)
487
495
  @wakes_paused = true
488
496
  else
489
497
  @continue_offer.after_turn(result)
@@ -491,6 +499,18 @@ module Samagotchi
491
499
  end
492
500
  end
493
501
 
502
+ # A failed wake turn kept nothing but its note: the update goes back to
503
+ # a plain note (no turn start, the background wording), so a reload
504
+ # draws no empty turn for it.
505
+ def unmark_wake_note(turn_id, note)
506
+ messages = @engine.messages_checkpoint
507
+ index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
508
+ return unless index
509
+
510
+ messages[index] = ContextNote.message(**note)
511
+ @engine.rollback_to(messages)
512
+ end
513
+
494
514
  # Whether the session has had a turn: a user or assistant message.
495
515
  def conversation_started?
496
516
  Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
@@ -551,11 +571,11 @@ module Samagotchi
551
571
  @engine.clear_due_reminder_names!
552
572
  return false unless @engine.reminders_due?
553
573
 
554
- @continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
574
+ @continue_offer.drop({ client_id: ClientId::REMINDER })
555
575
  # A failure has no prompt to hand back (the Engine announced
556
576
  # :turn_failed). The offer went before the turn
557
577
  # (ContinueOffer#drop); either way the rollback window closes.
558
- run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
578
+ run_engine_turn(nil, continue: true, origin: { client_id: ClientId::REMINDER },
559
579
  max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
560
580
  true
561
581
  end
@@ -597,7 +617,11 @@ module Samagotchi
597
617
  @turn_flow.before_prompt_turn
598
618
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
599
619
  if error
600
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
620
+ # The rings stay (settle_child_reports): the user's next turn takes them.
621
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
622
+ wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
623
+ kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
624
+ "again with the user's next message.")
601
625
  @turn_flow.prompt_turn_failed(note: note)
602
626
  @wakes_paused = true
603
627
  else
@@ -759,8 +783,11 @@ module Samagotchi
759
783
  rescue StandardError => e
760
784
  error = e
761
785
  # Still running: no turn of the Engine's ended (it never began).
786
+ # It ends here, failed, as an Engine turn would record it: a wait
787
+ # (ReplyWait) tells a turn's end by last_turn.
762
788
  if @session.status == Session::STATUS_RUNNING
763
789
  @session.status = Session::STATUS_IDLE
790
+ @engine.record_last_turn(@session, "failed", 0, turn_args[:origin], { type: :turn_failed })
764
791
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
765
792
  end
766
793
  ensure
@@ -800,12 +827,12 @@ module Samagotchi
800
827
  # Whether the turn starting now is a delegate child's parent's: its
801
828
  # task (the initial prompt), a follow-up, a continue the parent
802
829
  # answered, a reminder, anything but a human typing into the child
803
- # (ArchiveStore.user_input?), and any turn after a question the parent
830
+ # (ClientId.human?), and any turn after a question the parent
804
831
  # was rung about.
805
832
  def reports_to_parent?(origin)
806
833
  return false unless @session.delegate?
807
834
 
808
- @initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
835
+ @initial_turn || @owes_parent || !ClientId.human?(origin&.dig(:client_id))
809
836
  end
810
837
 
811
838
  # The Engine published a question (after the question desk saved it).
@@ -1023,11 +1050,11 @@ module Samagotchi
1023
1050
  Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
1024
1051
  end
1025
1052
 
1026
- # A human's input (ArchiveStore.user_input?) un-archives the session;
1053
+ # A human's input (ClientId.human?) un-archives the session;
1027
1054
  # a delegate's, a plugin's or a reminder's doesn't.
1028
1055
  # It also resets the delegate-report wakes (budget and pause).
1029
1056
  def user_input(client_id)
1030
- return unless ArchiveStore.user_input?(client_id)
1057
+ return unless ClientId.human?(client_id)
1031
1058
 
1032
1059
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1033
1060
  @wakes_in_a_row = 0
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.32.0
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -169,6 +169,9 @@ files:
169
169
  - lib/samagotchi/bundles/btw/plugin.rb
170
170
  - lib/samagotchi/bundles/check-in/manifest.yml
171
171
  - lib/samagotchi/bundles/check-in/plugin.rb
172
+ - lib/samagotchi/bundles/coordinator/manifest.yml
173
+ - lib/samagotchi/bundles/coordinator/plugin.rb
174
+ - lib/samagotchi/bundles/coordinator/skill_coordinator.md
172
175
  - lib/samagotchi/bundles/core/manifest.yml
173
176
  - lib/samagotchi/bundles/dev/manifest.yml
174
177
  - lib/samagotchi/bundles/github-pr/manifest.yml
@@ -198,11 +201,13 @@ files:
198
201
  - lib/samagotchi/bundles/system/self_map.md
199
202
  - lib/samagotchi/cancellation_controller.rb
200
203
  - lib/samagotchi/child_reports.rb
204
+ - lib/samagotchi/children_status.rb
201
205
  - lib/samagotchi/cli/command.rb
202
206
  - lib/samagotchi/cli/exit.rb
203
207
  - lib/samagotchi/cli/flags.rb
204
208
  - lib/samagotchi/cli/parent_wait.rb
205
209
  - lib/samagotchi/client.rb
210
+ - lib/samagotchi/client_id.rb
206
211
  - lib/samagotchi/command_steps.rb
207
212
  - lib/samagotchi/commands/registry.rb
208
213
  - lib/samagotchi/config.rb
@@ -219,6 +224,7 @@ files:
219
224
  - lib/samagotchi/context_usage.rb
220
225
  - lib/samagotchi/context_window.rb
221
226
  - lib/samagotchi/continue_offer.rb
227
+ - lib/samagotchi/cut_policy.rb
222
228
  - lib/samagotchi/debug_log.rb
223
229
  - lib/samagotchi/desktop.rb
224
230
  - lib/samagotchi/desktop/macos.rb
@@ -235,6 +241,7 @@ files:
235
241
  - lib/samagotchi/edit_preview.rb
236
242
  - lib/samagotchi/empty_answer_retry.rb
237
243
  - lib/samagotchi/engine.rb
244
+ - lib/samagotchi/engine_peers.rb
238
245
  - lib/samagotchi/events.rb
239
246
  - lib/samagotchi/gem_update.rb
240
247
  - lib/samagotchi/generation_phase.rb
@@ -242,6 +249,7 @@ files:
242
249
  - lib/samagotchi/guardrails.rb
243
250
  - lib/samagotchi/guardrails/approval.rb
244
251
  - lib/samagotchi/guardrails/approvals.rb
252
+ - lib/samagotchi/guardrails/child_boundary.rb
245
253
  - lib/samagotchi/guardrails/context.rb
246
254
  - lib/samagotchi/guardrails/gate.rb
247
255
  - lib/samagotchi/guardrails/load_failures.rb
@@ -254,6 +262,7 @@ files:
254
262
  - lib/samagotchi/guardrails/rm_targets.rb
255
263
  - lib/samagotchi/guardrails/rules.rb
256
264
  - lib/samagotchi/guardrails/scratch_writes.rb
265
+ - lib/samagotchi/guardrails/shared_git.rb
257
266
  - lib/samagotchi/guardrails/shell_git_dirs.rb
258
267
  - lib/samagotchi/guardrails/shell_lex.rb
259
268
  - lib/samagotchi/guardrails/shell_paths.rb
@@ -334,6 +343,7 @@ files:
334
343
  - lib/samagotchi/plugin/tool_result.rb
335
344
  - lib/samagotchi/plugin_session_state.rb
336
345
  - lib/samagotchi/plugin_tasks.rb
346
+ - lib/samagotchi/process_group.rb
337
347
  - lib/samagotchi/project_scope.rb
338
348
  - lib/samagotchi/prompt.rb
339
349
  - lib/samagotchi/prompt_history.rb
@@ -364,6 +374,7 @@ files:
364
374
  - lib/samagotchi/session_retention.rb
365
375
  - lib/samagotchi/sessions_command.rb
366
376
  - lib/samagotchi/steer.rb
377
+ - lib/samagotchi/steer_cut.rb
367
378
  - lib/samagotchi/system_prompt.rb
368
379
  - lib/samagotchi/terminal_ui.rb
369
380
  - lib/samagotchi/terminal_ui/attach_launcher.rb
@@ -483,6 +494,8 @@ files:
483
494
  - lib/samagotchi/web/public/sentences.js
484
495
  - lib/samagotchi/web/public/session_state.js
485
496
  - lib/samagotchi/web/public/sessions_list.js
497
+ - lib/samagotchi/web/public/shown_texts.js
498
+ - lib/samagotchi/web/public/stable_html.js
486
499
  - lib/samagotchi/web/public/stage_model.js
487
500
  - lib/samagotchi/web/public/stage_view.js
488
501
  - lib/samagotchi/web/public/strip.js