samagotchi 0.53.0 → 0.54.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  15. data/lib/samagotchi/bundle_command.rb +31 -36
  16. data/lib/samagotchi/bundle_needs.rb +4 -5
  17. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +6 -4
  22. data/lib/samagotchi/config.rb +0 -1
  23. data/lib/samagotchi/context_command.rb +1 -1
  24. data/lib/samagotchi/context_providers.rb +11 -14
  25. data/lib/samagotchi/context_status.rb +10 -18
  26. data/lib/samagotchi/engine.rb +117 -134
  27. data/lib/samagotchi/extension_load.rb +102 -0
  28. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  29. data/lib/samagotchi/kernel_loop.rb +5 -3
  30. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  31. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  32. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  33. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  34. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  35. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  36. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  37. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  39. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  41. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  43. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  44. data/lib/samagotchi/plugin/loader.rb +8 -8
  45. data/lib/samagotchi/self_report.rb +2 -2
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session_continue.rb +275 -0
  48. data/lib/samagotchi/session_manager.rb +9 -253
  49. data/lib/samagotchi/session_metrics.rb +3 -1
  50. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  51. data/lib/samagotchi/terminal_ui.rb +12 -2
  52. data/lib/samagotchi/tool_activity.rb +84 -14
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +1 -1
  55. data/lib/samagotchi/web/public/activity.js +2 -0
  56. data/lib/samagotchi/web/public/app.js +6 -6
  57. data/lib/samagotchi/web/public/ctx.js +18 -4
  58. data/lib/samagotchi/web/public/index.html +4 -2
  59. data/lib/samagotchi/web/public/question_card.js +9 -1
  60. data/lib/samagotchi/web/public/question_cards.js +5 -3
  61. data/lib/samagotchi/web/public/timing.js +8 -2
  62. data/lib/samagotchi/web/public/turn_html.js +10 -6
  63. metadata +7 -1
@@ -43,8 +43,10 @@ module Samagotchi
43
43
  action: tool_activity_action(tool_name, registry: registry, call: call),
44
44
  tool: tool_name,
45
45
  params: tool_activity_params(tool_name, call, registry: registry),
46
- status: tool_activity_status(result, tool_name)
46
+ status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
47
47
  }
48
+ # A grep that found nothing: ok, and the web row says "no match".
49
+ event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
48
50
  title = tool_title(tool_name, call, cwd: cwd)
49
51
  event[:title] = title if title
50
52
  # The name the model used when an alias ran a built-in (called_as: "bash"
@@ -181,13 +183,6 @@ module Samagotchi
181
183
  end
182
184
  end
183
185
 
184
- # "error" for an "Error:" result, and for an execute whose command
185
- # exited non-zero (its last "exit: N" line; no number: killed by a
186
- # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
187
- # task_wait the user's Stop ended (the task itself runs on), for one
188
- # whose task was stopped (the stop-task button, or the model's task_stop),
189
- # and for a command the user's Stop killed or kept from starting: a
190
- # cancel, not a failure (its text still starts "Error:" for the model).
191
186
  # memory_write's label: a call with only a description changes the index line.
192
187
  def memory_write_action(call)
193
188
  return "saving memory" unless call.is_a?(Hash)
@@ -205,23 +200,98 @@ module Samagotchi
205
200
  request.ids.empty? && request.restore? ? "restoring outputs" : "forgetting outputs"
206
201
  end
207
202
 
208
- def tool_activity_status(result, tool_name = nil)
203
+ # "error" for an "Error:" result, and for an execute whose command
204
+ # exited non-zero (its last "exit: N" line; no number: killed by a
205
+ # signal), so the turn tally's "(N failed)" counts it; but not a grep
206
+ # that found nothing (no_match?, given the +command+): that stays "ok".
207
+ # "stopped" for a
208
+ # task_wait the user's Stop ended (the task itself runs on), for one
209
+ # whose task was stopped (the stop-task button, or the model's task_stop),
210
+ # and for a command the user's Stop killed or kept from starting: a
211
+ # cancel, not a failure (its text still starts "Error:" for the model).
212
+ def tool_activity_status(result, tool_name = nil, command: nil)
209
213
  text = result.to_s
210
214
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
211
215
  return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
212
216
  return "error" if text.start_with?("Error:")
213
- return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
217
+ if tool_name == Tools::Execute::NAME && execute_failed?(text)
218
+ return no_match?(text, command) ? "ok" : "error"
219
+ end
214
220
  return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
215
221
 
216
222
  "ok"
217
223
  end
218
224
 
219
225
  def execute_failed?(text)
220
- exit_line = text.lines.reverse_each.find { |line| line.start_with?("exit: ") }
221
- return false unless exit_line
226
+ code = execute_exit_code(text)
227
+ !code.nil? && code != "0"
228
+ end
229
+
230
+ # The code on an execute result's last "exit: N" line ("" when killed
231
+ # by a signal); nil with no exit line.
232
+ def execute_exit_code(text)
233
+ exit_line = text.to_s.lines.reverse_each.find { |line| line.start_with?("exit: ") }
234
+ exit_line && exit_line[/\Aexit: (\d*)/, 1]
235
+ end
236
+
237
+ # The grep family: exit 1 means "searched, found nothing".
238
+ NO_MATCH_COMMANDS = %w[grep egrep fgrep rg ag pgrep].freeze
239
+ # Commands that can fail without a message, so an "&&" after them may
240
+ # hide which step exited 1.
241
+ SILENT_FAILERS = ["test", "[", "[[", "false", "diff", "cmp", "which", "type", "hash"].freeze
242
+ QUIET_FLAGS = %w[-q --quiet -s].freeze
243
+ # Any output sent to /dev/null (">", "1>", "2>", ">>"): its message may be gone.
244
+ TO_DEV_NULL = %r{>\s*/dev/null}
245
+ # Commands that end the script with their own code: the exit 1 may be theirs.
246
+ EXITS = %w[exit return].freeze
247
+
248
+ # An execute that exited 1 with no stderr block, whose last step is a
249
+ # grep-family command, and where no "&&" follows a step that could have
250
+ # failed silently: a search that found nothing, not a failure. Also
251
+ # false with a background "&" step (CommandSteps splits "&>" there too),
252
+ # an earlier exit/return, or set -e (any earlier step's failure ends the
253
+ # script). When unsure (no command, a command CommandSteps can't read),
254
+ # false.
255
+ def no_match?(text, command)
256
+ return false if command.nil? || execute_exit_code(text) != "1"
257
+ return false if text.to_s.each_line.any? { |line| line.chomp == "stderr:" }
258
+
259
+ steps = CommandSteps.parse(command)&.steps
260
+ return false unless steps && NO_MATCH_COMMANDS.include?(step_words(steps.last).first)
261
+ return false if steps.any? { |step| step.op == "&" }
262
+ return false if steps[0...-1].any? { |step| ends_script?(step) }
263
+
264
+ steps.each_cons(2).none? { |step, after| after.op == "&&" && silent_failer?(step) }
265
+ end
266
+
267
+ # A step's words from its command word on: no NAME=value prefixes, the
268
+ # command word without its path.
269
+ def step_words(step)
270
+ words = Guardrails::ShellLex.lex(step.text).filter_map { |kind, value| value if kind == :word }
271
+ words = words.drop_while { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/) }
272
+ return [] if words.empty?
273
+
274
+ [File.basename(words.first), *words.drop(1)]
275
+ end
276
+
277
+ # exit / return, or set -e (-e alone or in a flag group, -o errexit).
278
+ def ends_script?(step)
279
+ name, *args = step_words(step)
280
+ return true if EXITS.include?(name)
281
+ return false unless name == "set"
282
+
283
+ args.any? { |arg| arg.match?(/\A-[a-z]*e/) } || args.each_cons(2).include?(%w[-o errexit])
284
+ end
285
+
286
+ def silent_failer?(step)
287
+ return true if step.text.match?(TO_DEV_NULL) || step.text.lstrip.start_with?("((")
288
+
289
+ name, *args = step_words(step)
290
+ return true if SILENT_FAILERS.include?(name)
291
+ return true if name == "command" && args.intersect?(%w[-v -V])
292
+ return true if NO_MATCH_COMMANDS.include?(name) && args.intersect?(QUIET_FLAGS)
222
293
 
223
- code = exit_line[/\Aexit: (\d*)/, 1]
224
- code != "0"
294
+ name == "git" && args.first == "diff" && args.intersect?(%w[--quiet --exit-code])
225
295
  end
226
296
 
227
297
  def tool_activity_params(tool_name, call, registry: nil)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.53.0"
4
+ VERSION = "0.54.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -698,7 +698,7 @@ module Samagotchi
698
698
  # the new link started is not a refusal of it.
699
699
  def continue_from(given, prompt:, title:)
700
700
  [@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
701
- rescue SessionManager::ContinueRefused => e
701
+ rescue SessionContinue::Refused => e
702
702
  extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
703
703
  [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
704
704
  rescue SessionManager::ArchiveRefused => e
@@ -119,6 +119,8 @@ export function addCompleted(model, event = {}, now = null) {
119
119
  if (event.activity?.params) row.params = event.activity.params;
120
120
  if (event.activity?.title) row.title = event.activity.title;
121
121
  if (event.activity?.called_as) row.called_as = event.activity.called_as;
122
+ // A grep that found nothing (status ok): the row says "no match".
123
+ if (event.activity?.no_match) row.no_match = true;
122
124
  // The images the tool returned (a reload in the turn view draws them from
123
125
  // the saved parts, turn_html.js toolRowHtml).
124
126
  if (event.images?.length) row.images = event.images;
@@ -48,7 +48,7 @@ import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
49
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
50
50
  import { routeChunk } from "./chunk_router.js";
51
- import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
51
+ import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, ctxBarTip, generationTokens, lastSpeedText, speedText } from "./ctx.js";
52
52
  import { createQuestionCards } from "./question_cards.js";
53
53
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
54
54
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
@@ -462,7 +462,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
462
462
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
463
463
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
464
464
  const ctx = cardCtxText(s.ctx_pct);
465
- const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens, undefined, s.memory_index))}">${escapeHtml(ctx)}</span>` : "";
465
+ const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }))}">${escapeHtml(ctx)}</span>` : "";
466
466
  const waiting = waitingBadge(s);
467
467
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
468
468
  // A turn a hook stopped wears its own badge, unless the session waits on
@@ -2021,9 +2021,9 @@ function updateInfoBar() {
2021
2021
  const idShort = selected.slice(0,8);
2022
2022
  const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
2023
2023
  const tokens = current.timing.tokens;
2024
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
2025
- { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
2026
- budget: current.llmContext?.budget_tokens });
2024
+ const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
2025
+ { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
2026
+ budget: current.llmContext?.budget_tokens });
2027
2027
  const speedTextNow = lastSpeedText(tokens);
2028
2028
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
2029
2029
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -2049,7 +2049,7 @@ function updateInfoBar() {
2049
2049
  const attachCmd = `chi --attach ${selected}`;
2050
2050
  const copied = Date.now() - attachCopiedAt < 2000;
2051
2051
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
2052
- 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>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${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>`;
2052
+ 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>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${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><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>`;
2053
2053
  // The session clock ticks every second while a turn runs: only its text
2054
2054
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
2055
2055
  renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
@@ -62,9 +62,11 @@ export function savedCtxPct(context, budgetTokens = null) {
62
62
  return (used / against) * 100;
63
63
  }
64
64
 
65
- // A session card's quiet ctx: "12%", or "" when the list row has none.
65
+ // A session card's quiet ctx: "12%", "ctx ?" when the list row has none (the
66
+ // chip is always shown; "ctx ?" never means 0 — no request in this session
67
+ // yet; a lone "?" beside the folder would read as "? project").
66
68
  export function cardCtxText(pct) {
67
- return num(pct) === null ? "" : `${Math.round(pct)}%`;
69
+ return num(pct) === null ? "ctx ?" : `${Math.round(pct)}%`;
68
70
  }
69
71
 
70
72
  // A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
@@ -123,12 +125,24 @@ export function memoryIndexText(memoryIndex) {
123
125
 
124
126
  // The info bar's ctx: "ctx 12%", "ctx ~12%" when the window is chi's
125
127
  // default (no server, model list or setting gave one, so the percent is of
126
- // a guess); "" without a pct.
128
+ // a guess); "ctx ?" when there is no percent yet (no request in this session
129
+ // — the chip is always shown; "?" never means 0).
127
130
  export function ctxBarText(pct, windowSource = null) {
128
- if (num(pct) === null) return "";
131
+ if (num(pct) === null) return "ctx ?";
129
132
  return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
130
133
  }
131
134
 
135
+ // The info bar's / the card's ctx tooltip: the window line under the title
136
+ // when there's a number (tokensTipText), or why there's none — "No request
137
+ // in this session yet" — when the page has no percent (before any request;
138
+ // the page isn't told context.status is off, and the window always resolves,
139
+ // so "?" never means 0).
140
+ export function ctxBarTip(tokens, memoryIndex, window) {
141
+ if (num(window?.pct) === null) return "No request in this session yet";
142
+
143
+ return tokensTipText(tokens, undefined, memoryIndex, window);
144
+ }
145
+
132
146
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
133
147
  // session's window, where it came from, the meter's value and the LLM
134
148
  // context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
@@ -136,7 +136,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
136
136
  /* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
137
137
  no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
138
138
  blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
139
- An exception also shows its word after the title (.activity-state). */
139
+ An exception also shows its word after the title (.activity-state); a done grep that found nothing a muted
140
+ "no match" (.no-match). */
140
141
  .activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
141
142
  .activity-status.ok::before{content:"\2713";opacity:.85}
142
143
  .activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
@@ -149,6 +150,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
149
150
  :is(.activity-status,.activity-state).error{color:var(--danger)}
150
151
  :is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
151
152
  :is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
153
+ .activity-state.no-match{color:var(--text-muted);font-weight:500}
152
154
  .activity-tool{color:var(--accent);font-weight:600}
153
155
  /* The stop-task button on a running task_wait (task_stop.js): a small ghost
154
156
  button in the danger colours, at the row's end and in the stage's now slot. */
@@ -328,7 +330,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
328
330
  .question-freeform{width:100%;box-sizing:border-box;background:var(--bg-inset);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;margin-bottom:8px}
329
331
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
330
332
  .question-error.hidden{display:none}
331
- .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
333
+ .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600;white-space:pre-wrap;overflow-wrap:anywhere}
332
334
  /* 4.02: a resolved card whose summary already carries the result hides the
333
335
  body line; a hand-toggled one keeps it (its expanded body shows it). */
334
336
  .question-result.hidden{display:none}
@@ -145,7 +145,9 @@ export function truncate(text, n = 80) {
145
145
  // asks its tool and command: "execute: rm -rf tmp → Denied").
146
146
  export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
147
147
  if (cancelled || answer) {
148
- const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
148
+ // A deny's typed reason stays out of the one-liner (the body line has it).
149
+ const result = !cancelled && deniedReason(pq, answer) ? "Denied"
150
+ : resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
149
151
  const relay = relayView(pq);
150
152
  const asked = summaryAsked(pq);
151
153
  const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
@@ -165,6 +167,12 @@ function summaryAsked(pq) {
165
167
  return String(pq.question || pq.header || "").split("\n")[0];
166
168
  }
167
169
 
170
+ // The reason typed with an approval's deny ("" when none, or not a deny).
171
+ export function deniedReason(pq, answer) {
172
+ if (approvalAllowed(pq, answer) !== false) return "";
173
+ return answer && answer.freeform ? String(answer.freeform) : "";
174
+ }
175
+
168
176
  // Whether the answer allowed the call (the card turns green) or denied it.
169
177
  export function approvalAllowed(pq, answer) {
170
178
  if (!isApproval(pq)) return null;
@@ -15,7 +15,7 @@
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
17
  import {
18
- answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
18
+ answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, nextCardAction,
19
19
  relayedToView, resultText, summaryText,
20
20
  } from "./question_card.js";
21
21
  import { sessionHash } from "./route.js";
@@ -339,8 +339,10 @@ export function createQuestionCards({
339
339
  if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
340
340
  // 4.02: a reopened card's summary already carries the result (the
341
341
  // snapshot re-renders it resolved), and the body line repeats it. It
342
- // stays for a hand-toggled card, whose expanded body the user looks at.
343
- if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
342
+ // stays for a hand-toggled card, whose expanded body the user looks at,
343
+ // and for a deny with a reason: the summary says only "Denied".
344
+ const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
345
+ if (summary && !keepNote) note.classList.add("hidden");
344
346
  if (!questionEl.dataset.toggled) questionEl.open = false;
345
347
  pending = null;
346
348
  }
@@ -264,14 +264,20 @@ function canceledSteps(g, record, tools) {
264
264
  }
265
265
 
266
266
  // A call's row from its tool record alone (no saved parts): the record's
267
- // called_as too, so "(called as bash)" shows after a reload, as live.
267
+ // called_as too, so "(called as bash)" shows after a reload, as live, and
268
+ // its no_match ("no match" on a grep that found nothing).
268
269
  function recordRows(tools, iteration) {
269
270
  return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
270
271
  key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
271
272
  ...(r.called_as ? { called_as: r.called_as } : {}),
273
+ ...noMatch(r),
272
274
  }));
273
275
  }
274
276
 
277
+ function noMatch(record) {
278
+ return record?.no_match ? { no_match: true } : {};
279
+ }
280
+
275
281
  // A turn whose saved messages carry their parts (the server's ?parts=1):
276
282
  // every assistant message is one step. Its calls take the turn's records by
277
283
  // tool, in call order (each the next record of its tool, consumed with the
@@ -292,7 +298,7 @@ function partsTurn(g, items, record, tools, stepsOnly = false) {
292
298
  const rows = Array.isArray(parts.tools)
293
299
  ? parts.tools.map((call, j) => {
294
300
  const r = takeRecord(pool, call.tool);
295
- return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
301
+ return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...noMatch(r), ...call };
296
302
  })
297
303
  : [];
298
304
  const thinking = String(parts.thinking || "");
@@ -12,16 +12,20 @@ import { emptyAnswerLine } from "./turn_events.js";
12
12
 
13
13
  // A row's status (live, or a saved tool record's on reload): running,
14
14
  // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
15
- // denied it), else ok ("done").
15
+ // denied it), else ok ("done"; a grep that found nothing is ok too, with
16
+ // a "no match" word, see statusParts).
16
17
  const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
17
18
 
18
19
  // A row's status as its parts: the mark's class (CSS draws the glyph), its
19
20
  // label (aria-label and title) and the word an exception shows after the
20
- // title (empty for done and running).
21
- export function statusParts(status) {
21
+ // title (empty for done and running). A done grep that found nothing
22
+ // (+noMatch+, the activity's no_match) keeps the done mark and shows a
23
+ // muted "no match" word (its own class, wordCls).
24
+ export function statusParts(status, { noMatch = false } = {}) {
22
25
  const named = NAMED_STATUSES.includes(status);
23
26
  const cls = named ? status : "ok";
24
27
  const label = named ? status : "done";
28
+ if (cls === "ok" && noMatch) return { cls, label, word: "no match", wordCls: "no-match" };
25
29
  return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
26
30
  }
27
31
 
@@ -29,8 +33,8 @@ function statusMarkHtml({ cls, label }) {
29
33
  return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
30
34
  }
31
35
 
32
- function statusWordHtml({ cls, word }) {
33
- return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
36
+ function statusWordHtml({ cls, word, wordCls }) {
37
+ return word ? `<span class="activity-state ${wordCls || cls}">${word}</span>` : "";
34
38
  }
35
39
 
36
40
  // A reloaded row's ✂ mark: what an LLM context edit saved on its output
@@ -85,7 +89,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
85
89
  const output = full
86
90
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
87
91
  : "";
88
- const status = statusParts(row.status);
92
+ const status = statusParts(row.status, { noMatch: !!row.no_match });
89
93
  return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
90
94
  `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
91
95
  `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
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.53.0
4
+ version: 0.54.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -131,6 +131,7 @@ files:
131
131
  - docs/guardrails.md
132
132
  - docs/hooks.md
133
133
  - docs/internals/background-tasks.md
134
+ - docs/internals/bundles.md
134
135
  - docs/internals/context-telemetry.md
135
136
  - docs/internals/engine.md
136
137
  - docs/internals/gemma4-contract.md
@@ -258,6 +259,7 @@ files:
258
259
  - lib/samagotchi/engine.rb
259
260
  - lib/samagotchi/engine_peers.rb
260
261
  - lib/samagotchi/events.rb
262
+ - lib/samagotchi/extension_load.rb
261
263
  - lib/samagotchi/gem_update.rb
262
264
  - lib/samagotchi/generation_phase.rb
263
265
  - lib/samagotchi/git_head.rb
@@ -332,16 +334,19 @@ files:
332
334
  - lib/samagotchi/log_path.rb
333
335
  - lib/samagotchi/log_subscriber.rb
334
336
  - lib/samagotchi/memory_bundle.rb
337
+ - lib/samagotchi/memory_bundle/asset_installer.rb
335
338
  - lib/samagotchi/memory_bundle/builder.rb
336
339
  - lib/samagotchi/memory_bundle/bundle_hook.rb
337
340
  - lib/samagotchi/memory_bundle/index_size.rb
338
341
  - lib/samagotchi/memory_bundle/index_sync.rb
339
342
  - lib/samagotchi/memory_bundle/index_updater.rb
343
+ - lib/samagotchi/memory_bundle/installed_bundle.rb
340
344
  - lib/samagotchi/memory_bundle/installer.rb
341
345
  - lib/samagotchi/memory_bundle/listing.rb
342
346
  - lib/samagotchi/memory_bundle/manifest.rb
343
347
  - lib/samagotchi/memory_bundle/merger.rb
344
348
  - lib/samagotchi/memory_bundle/placeholder.rb
349
+ - lib/samagotchi/memory_bundle/plugin_ref.rb
345
350
  - lib/samagotchi/memory_bundle/profile.rb
346
351
  - lib/samagotchi/memory_bundle/provenance.rb
347
352
  - lib/samagotchi/memory_bundle/shipped_update.rb
@@ -405,6 +410,7 @@ files:
405
410
  - lib/samagotchi/session_archive_command.rb
406
411
  - lib/samagotchi/session_chain.rb
407
412
  - lib/samagotchi/session_commands.rb
413
+ - lib/samagotchi/session_continue.rb
408
414
  - lib/samagotchi/session_delete_command.rb
409
415
  - lib/samagotchi/session_inbox.rb
410
416
  - lib/samagotchi/session_manager.rb