samagotchi 0.52.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -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/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -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.52.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
@@ -9,6 +9,7 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../analytics_file"
12
13
  require_relative "../client_id"
13
14
  require_relative "../delivery"
14
15
  require_relative "../answer_tail"
@@ -697,7 +698,7 @@ module Samagotchi
697
698
  # the new link started is not a refusal of it.
698
699
  def continue_from(given, prompt:, title:)
699
700
  [@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
700
- rescue SessionManager::ContinueRefused => e
701
+ rescue SessionContinue::Refused => e
701
702
  extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
702
703
  [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
703
704
  rescue SessionManager::ArchiveRefused => e
@@ -1722,13 +1723,7 @@ module Samagotchi
1722
1723
  end
1723
1724
 
1724
1725
  def read_analytics(session_id)
1725
- path = File.join(@session_class.session_dir(session_id, state_dir: default_state_dir), "analytics.json")
1726
- return {} unless File.file?(path)
1727
-
1728
- data = JSON.parse(File.read(path))
1729
- data.is_a?(Hash) ? data : {}
1730
- rescue JSON::ParserError, SystemCallError
1731
- {}
1726
+ AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1732
1727
  end
1733
1728
 
1734
1729
  def merge_timing_records(persisted, live)
@@ -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";
@@ -126,7 +126,6 @@ const llmContextNewControl = createLlmContextControl({
126
126
  // in the session's worker.
127
127
  const thinkingControl = createThinkingControl({
128
128
  el: infoTextEl,
129
- escapeHtml,
130
129
  summary: () => (selected ? current.thinking : null),
131
130
  session: () => selected,
132
131
  running: () => liveTurn.running,
@@ -139,7 +138,6 @@ let newChatThinking = null;
139
138
  const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
140
139
  const thinkingNewControl = createThinkingControl({
141
140
  el: thinkingNewEl,
142
- escapeHtml,
143
141
  summary: newChatThinkingSummary,
144
142
  kind: NEW_CHAT,
145
143
  choice: () => newChatThinking,
@@ -464,7 +462,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
464
462
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
465
463
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
466
464
  const ctx = cardCtxText(s.ctx_pct);
467
- 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>` : "";
468
466
  const waiting = waitingBadge(s);
469
467
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
470
468
  // A turn a hook stopped wears its own badge, unless the session waits on
@@ -2023,9 +2021,9 @@ function updateInfoBar() {
2023
2021
  const idShort = selected.slice(0,8);
2024
2022
  const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
2025
2023
  const tokens = current.timing.tokens;
2026
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
2027
- { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
2028
- 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 });
2029
2027
  const speedTextNow = lastSpeedText(tokens);
2030
2028
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
2031
2029
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -2051,7 +2049,7 @@ function updateInfoBar() {
2051
2049
  const attachCmd = `chi --attach ${selected}`;
2052
2050
  const copied = Date.now() - attachCopiedAt < 2000;
2053
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>`;
2054
- 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>`;
2055
2053
  // The session clock ticks every second while a turn runs: only its text
2056
2054
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
2057
2055
  renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
@@ -4,17 +4,22 @@
4
4
 
5
5
  // The commands to offer while the composer holds one "/word" being typed
6
6
  // (no space or newline yet): the session's slash commands that start with
7
- // it, by name. The terminal UIs' own (local: /stats, /exit …) don't run here.
7
+ // it, by name. A UI's own (local) one is offered only when its uis name
8
+ // the web (/cut); the terminals' own (/stats, /exit …) don't run here.
8
9
  export function commandMatches(commands, text) {
9
10
  const typed = String(text || "");
10
11
  if (!/^\/\S*$/.test(typed)) return [];
11
12
  const needle = typed.toLowerCase();
12
13
  return (Array.isArray(commands) ? commands : [])
13
- .filter((c) => c && !c.local && typeof c.name === "string" && c.name.startsWith("/"))
14
+ .filter((c) => c && runsInWeb(c) && typeof c.name === "string" && c.name.startsWith("/"))
14
15
  .filter((c) => c.name.toLowerCase().startsWith(needle))
15
16
  .sort((a, b) => a.name.localeCompare(b.name));
16
17
  }
17
18
 
19
+ function runsInWeb(command) {
20
+ return !command.local || (Array.isArray(command.uis) && command.uis.includes("web"));
21
+ }
22
+
18
23
  // What picking +name+ does to the composer: {text, send}. The name
19
24
  // completes with a space for its arguments; ⏎ on a name already typed in
20
25
  // full sends it as it is (⏎ on "/help" runs /help).
@@ -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
  }
@@ -12,6 +12,7 @@
12
12
  // nothing is sent unless the user sets one.
13
13
 
14
14
  import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
15
+ import { html, nothing, render } from "./vendor/lit-html.js";
15
16
 
16
17
  export const LEVELS = ["off", "low", "medium", "high"];
17
18
  const DEFAULT = "default";
@@ -36,6 +37,8 @@ export function chipModel(summary, { kind = SESSION } = {}) {
36
37
  return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
37
38
  }
38
39
 
40
+ // The session bar's chip, as a string: the info bar is still built as one
41
+ // (app.js); it becomes a template when the info bar moves to lit.
39
42
  export function chipHtml(summary, escapeHtml) {
40
43
  const chip = chipModel(summary);
41
44
  if (!chip) return "";
@@ -50,32 +53,31 @@ export function commandLine(level, initial = null) {
50
53
  return `/thinking ${level}`;
51
54
  }
52
55
 
53
- function option(value, label, chosen, escapeHtml) {
54
- return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
55
- }
56
-
57
56
  // "Now: low (session)", "Now: default (the model's own)".
58
57
  export function nowText(summary) {
59
58
  return `Now: ${summary.level} (${summary.source || "the model's own"})`;
60
59
  }
61
60
 
62
- // The popover's form. For the new chat (kind NEW_CHAT), +summary+ is the
63
- // model's and +choice+ the level the form shows.
64
- export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
61
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
62
+
63
+ // The popover's form (a lit-html template). For the new chat (kind
64
+ // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
65
+ // shows; +now+ is the summary its "Now:" line reads (refresh passes a
66
+ // newer one, the rest stays as the form opened).
67
+ export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
65
68
  const fresh = kind === NEW_CHAT;
66
69
  const chosen = (fresh ? choice : summary.own) || DEFAULT;
67
70
  const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
68
71
  const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
69
72
  : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
70
- return `<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong>` +
71
- `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
72
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
73
- `<div class="ctx-pop-meta think-now">${escapeHtml(nowText(summary))}</div>` +
74
- `<label class="llmctx-field"><span>Level</span><select name="level">` +
75
- option(DEFAULT, follow, chosen, escapeHtml) +
76
- LEVELS.map((level) => option(level, level, chosen, escapeHtml)).join("") + `</select></label>` +
77
- `<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
78
- `<div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
73
+ return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
74
+ ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
75
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
76
+ ><div class="ctx-pop-meta think-now">${nowText(now)}</div
77
+ ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
78
+ LEVELS.map((level) => option(level, level, chosen))}</select></label
79
+ ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
80
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
79
81
  }
80
82
 
81
83
  // The chip's popover, as createLlmContextControl's: el is where the chip
@@ -86,7 +88,7 @@ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION,
86
88
  // and choose(next) takes a Set's (null for "follow the model"). id and
87
89
  // anchor (the chip's selector) keep it apart from the llm ctx control on
88
90
  // the same element.
89
- export function createThinkingControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
91
+ export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
90
92
  kind = SESSION, choice = () => null, choose = null,
91
93
  id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
92
94
  doc = document, win = window }) {
@@ -99,6 +101,11 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
99
101
  doc.body.appendChild(pop);
100
102
  let initial = null;
101
103
  let openFor = null;
104
+ // What the open form was drawn from: the model's or session's summary
105
+ // and the form's options. A closed popover is empty, so each open draws
106
+ // a fresh form (an unsent pick is gone).
107
+ let shown = null;
108
+ const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
102
109
 
103
110
  function place(anchor) {
104
111
  const width = Math.min(320, win.innerWidth - 32);
@@ -117,7 +124,8 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
117
124
  if (!data) return;
118
125
  initial = (fresh ? choice() : data.own) || DEFAULT;
119
126
  openFor = session();
120
- pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
127
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
128
+ draw(data);
121
129
  pop.hidden = false;
122
130
  place(anchor);
123
131
  }
@@ -126,17 +134,19 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
126
134
  pop.hidden = true;
127
135
  initial = null;
128
136
  openFor = null;
137
+ shown = null;
138
+ render(nothing, pop);
129
139
  }
130
140
 
131
141
  function isOpen() { return !pop.hidden; }
132
142
 
133
- // A new summary: an open form keeps the user's pick; only "Now:" follows.
143
+ // A new summary: an open form keeps the user's pick; only "Now:" follows
144
+ // (the same template again: lit writes that line alone).
134
145
  function refresh() {
135
146
  if (!isOpen()) return;
136
147
  const data = summary();
137
148
  if (!data || session() !== openFor) return close();
138
- const now = pop.querySelector(".think-now");
139
- if (now) now.textContent = nowText(data);
149
+ draw(data);
140
150
  }
141
151
 
142
152
  el.addEventListener("click", (e) => {
@@ -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}` +