samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
@@ -24,6 +24,9 @@
24
24
  # thinking: the thinking watch (ThinkingWatch::DEFAULTS)
25
25
  # watch: true false: the tool-call guard only
26
26
  # action: retry retry (cut, ask again; then stop) | stop | notify
27
+ # forget_after: 10 good steps (generations with no loop) in a row after a
28
+ # cut loop, and the next loop is cut again, not stopped;
29
+ # 0: never forget, the turn's second loop stops it
27
30
  require "digest"
28
31
 
29
32
  # Sees thinking repeat itself, from the deltas of one generation.
@@ -44,12 +47,19 @@ require "digest"
44
47
  # of 2 or more needs a cycle of distinct sentences (not all alike), and a
45
48
  # period of 1 (one sentence over and over) needs `similarity` +
46
49
  # SAME_MARGIN (0.9 by default: near-identical);
47
- # - the same sentence max_same times in the generation, in any order.
50
+ # - the same sentence max_same times in the generation, in any order;
51
+ # - a short run: short_run short sentences (under min_words) in a row, at
52
+ # most short_distinct different ones among the last short_run ("I'll
53
+ # write it. Go. OK. Writing. Go."). Normal thinking has short sentences
54
+ # too ("Hmm.", "Fine."), but spread out, or in runs of different ones
55
+ # (code lines); a sentence of min_words or more ends the run, one with no
56
+ # letters or digits neither counts nor ends it. Lines inside a ``` code
57
+ # block are not short sentences.
48
58
  # Nothing triggers before min_chars of thinking. O(new chars) per feed.
49
59
  class ThinkingWatch
50
60
  DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
51
61
  "similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
52
- "min_words" => 5 }.freeze
62
+ "min_words" => 5, "short_run" => 24, "short_distinct" => 6, "forget_after" => 10 }.freeze
53
63
  ACTIONS = %w[retry stop notify].freeze
54
64
  MAX_CARRY = 400
55
65
  # One sentence again and again needs this much more alike than a cycle.
@@ -68,10 +78,13 @@ class ThinkingWatch
68
78
  out = DEFAULTS.dup
69
79
  out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
70
80
  out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
71
- %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words].each do |key|
81
+ %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words short_run
82
+ short_distinct].each do |key|
72
83
  number = Integer(raw[key].to_s, exception: false)
73
84
  out[key] = number if number&.positive?
74
85
  end
86
+ forget = Integer(raw["forget_after"].to_s, exception: false)
87
+ out["forget_after"] = forget if forget && forget >= 0
75
88
  similarity = Float(raw["similarity"].to_s, exception: false)
76
89
  out["similarity"] = similarity if similarity && similarity.positive? && similarity <= 1
77
90
  out["repeats"] = 2 if out["repeats"] < 2
@@ -87,12 +100,17 @@ class ThinkingWatch
87
100
  @min_span_chars = settings["min_span_chars"]
88
101
  @max_same = settings["max_same"]
89
102
  @min_words = settings["min_words"]
103
+ @short_run = settings["short_run"]
104
+ @short_distinct = settings["short_distinct"]
90
105
  @same_similarity = [@similarity + SAME_MARGIN, 0.95].min
91
106
  @carry = +""
92
107
  @window = [] # the last max_period + 1 sentences
93
108
  @run = Array.new(@max_period + 1, 0)
94
109
  @run_chars = Array.new(@max_period + 1, 0) # chars of the run's sentences plus the first cycle's
95
110
  @counts = Hash.new(0)
111
+ @short_count = 0 # short sentences in a row
112
+ @short_window = [] # the last short_run of them, [key, text]
113
+ @in_code = false
96
114
  @thinking_chars = 0
97
115
  end
98
116
 
@@ -115,7 +133,9 @@ class ThinkingWatch
115
133
  # The complete sentences in the carry; the unfinished tail stays.
116
134
  def sentences
117
135
  parts = @carry.split(SPLIT, -1)
118
- @carry = parts.pop.to_s
136
+ # An empty carry splits into no parts: nil.to_s is frozen, and the next
137
+ # feed's << would raise (the watch went blind for that generation).
138
+ @carry = +parts.pop.to_s
119
139
  if @carry.length > MAX_CARRY
120
140
  cut = @carry.rindex(" ", MAX_CARRY) || MAX_CARRY
121
141
  parts << @carry[0, cut]
@@ -125,8 +145,13 @@ class ThinkingWatch
125
145
  end
126
146
 
127
147
  def add(text)
148
+ if text.strip.start_with?("```")
149
+ @in_code = !@in_code
150
+ return nil
151
+ end
128
152
  words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
129
- return nil if words.length < @min_words
153
+ short = short_loop(text.strip, words) unless @in_code
154
+ return (short if @thinking_chars >= @min_chars) if words.length < @min_words
130
155
 
131
156
  key = words.join(" ")
132
157
  sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
@@ -140,6 +165,25 @@ class ThinkingWatch
140
165
  cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
141
166
  end
142
167
 
168
+ def short_loop(text, words)
169
+ return nil if words.empty?
170
+
171
+ if words.length >= @min_words
172
+ @short_count = 0
173
+ @short_window.clear
174
+ return nil
175
+ end
176
+ @short_count += 1
177
+ @short_window << [words.join(" "), text]
178
+ @short_window.shift if @short_window.length > @short_run
179
+ return nil if @short_window.length < @short_run
180
+
181
+ distinct = @short_window.uniq(&:first)
182
+ return nil if distinct.length > @short_distinct
183
+
184
+ Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil)
185
+ end
186
+
143
187
  def count(key)
144
188
  return @counts[key] += 1 if @counts.key?(key) || @counts.size < MAX_DISTINCT
145
189
 
@@ -209,6 +253,7 @@ class Plugin
209
253
  return unless @thinking["watch"]
210
254
 
211
255
  chi.on(:before_generation) do |_event|
256
+ good_step if @watch
212
257
  @watch = ThinkingWatch.new(@thinking)
213
258
  @watch_done = false
214
259
  end
@@ -242,6 +287,16 @@ class Plugin
242
287
  end
243
288
  end
244
289
 
290
+ # The generation before this one ended with no loop: a good step. After
291
+ # forget_after of them in a row the turn's loops are forgotten, so a loop
292
+ # long after the model recovered is cut and retried, not stopped.
293
+ def good_step
294
+ return @good_steps = 0 if @watch_done
295
+
296
+ @good_steps += 1
297
+ @thinking_loops = 0 if @good_steps == @thinking["forget_after"]
298
+ end
299
+
245
300
  def stop_thinking(ctx, found, what)
246
301
  return unless ctx.stop_turn("the model's thinking kept repeating itself (#{what})")
247
302
 
@@ -261,6 +316,7 @@ class Plugin
261
316
  @denials = 0
262
317
  @pending = nil
263
318
  @thinking_loops = 0
319
+ @good_steps = 0
264
320
  @watch = nil
265
321
  end
266
322
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.0
3
+ version: 0.4.1
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
10
+ sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
11
11
  requires_chi: ">= 0.8.0"
@@ -218,7 +218,11 @@ class Plugin
218
218
 
219
219
  def ended
220
220
  status = @wait.value
221
- reason = "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
221
+ reason = if status.nil?
222
+ "the server exited"
223
+ else
224
+ "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
225
+ end
222
226
  waiting, closing = @mutex.synchronize do
223
227
  @dead ||= reason
224
228
  [@pending.values, @closing]
@@ -652,7 +656,7 @@ class Plugin
652
656
 
653
657
  notes = paths.map do |path|
654
658
  images << { path: path, name: File.basename(path) }
655
- "[image #{images.size}: #{File.basename(path)}, attached]"
659
+ "[image #{images.size}: #{File.basename(path)}]"
656
660
  end
657
661
  [line, *notes].join("\n")
658
662
  when "image"
@@ -661,11 +665,21 @@ class Plugin
661
665
  next "[image: #{mime}, empty]" if bytes.empty?
662
666
 
663
667
  images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
664
- "[image #{images.size}: #{mime}, attached]"
668
+ "[image #{images.size}: #{mime}]"
665
669
  when "audio" then "[audio: #{block["mimeType"] || "unknown type"}]"
666
670
  when "resource"
667
671
  resource = block["resource"] || {}
668
- resource["text"] || "[resource: #{resource["uri"]}]"
672
+ mime = resource["mimeType"].to_s
673
+ blob = resource["blob"].to_s
674
+ if mime.start_with?("image/") && !blob.empty?
675
+ bytes = blob.unpack1("m")
676
+ next "[image: #{mime}, empty]" if bytes.empty?
677
+
678
+ images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
679
+ "[image #{images.size}: #{mime}]"
680
+ else
681
+ resource["text"] || "[resource: #{resource["uri"]}]"
682
+ end
669
683
  when "resource_link" then "[resource link: #{block["uri"]}]"
670
684
  else "[#{block["type"] || "unknown"} content]"
671
685
  end
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.19.0
3
+ version: 0.21.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
9
  config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
10
- memory_guide.md: sha256:8819678a243590203fa439ad736bf1959af4b4677bc6d2d507bad92c46fb1ccc
10
+ memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
95
95
 
96
96
  **Scope resolution for install/build:**
97
- - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
97
+ - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`MemoryPaths.scope_dir`).
98
98
 
99
99
  **Example flows:**
100
100
  ```bash
@@ -28,8 +28,10 @@ module Samagotchi
28
28
  def in_ui?(ui) = uis.nil? || uis.include?(ui)
29
29
  end
30
30
 
31
- def initialize
31
+ # @param entries [Array<Entry>] taken as they are, in order (.from_listing)
32
+ def initialize(entries: [])
32
33
  @entries = []
34
+ entries.each { |entry| add(entry) }
33
35
  end
34
36
 
35
37
  # A UI without an Engine (attached) learns the session's commands from
@@ -39,8 +41,7 @@ module Samagotchi
39
41
  # @param listing [Array<Hash>] #listing, with String or Symbol keys
40
42
  # @return [Registry]
41
43
  def self.from_listing(listing, base:)
42
- registry = new
43
- base.entries.each { |entry| registry.send(:add, entry) }
44
+ registry = new(entries: base.entries)
44
45
  known = base.entries.map(&:name)
45
46
  Array(listing).each do |item|
46
47
  item = item.transform_keys(&:to_sym)
@@ -107,6 +107,9 @@ module Samagotchi
107
107
  # The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
108
108
  # own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
109
109
  Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
110
+ # Which rules vote: auto (the default) leaves out the rules tagged `modes: [strict]`; strict runs them all. An unknown
111
+ # value warns and is auto.
112
+ Entry.new(key: "guardrails.mode", yaml_path: %w[guardrails mode], type: :enum, default: "auto", expose: %i[config], enum_values: %w[auto strict]),
110
113
  # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
111
114
  # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
112
115
  Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
@@ -26,9 +26,9 @@ module Samagotchi
26
26
  usage = {} unless usage.is_a?(Hash)
27
27
 
28
28
  # The token counts come from the same parser SessionMetrics uses.
29
- counts = TokenUsage.from_payload(payload_hash) || {}
30
- prompt_tokens = counts[:prompt_tokens]
31
- completion_tokens = counts[:completion_tokens]
29
+ counts = TokenUsage.from_payload(payload_hash)
30
+ prompt_tokens = counts&.prompt_tokens
31
+ completion_tokens = counts&.completion_tokens
32
32
 
33
33
  total_tokens = first_positive_integer(
34
34
  usage["total_tokens"], usage[:total_tokens],
@@ -1637,12 +1637,25 @@ module Samagotchi
1637
1637
  def stopped_before_model(turn)
1638
1638
  reason = turn.controller.reason
1639
1639
  end_turn(turn, "canceled") do |seconds|
1640
- [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1640
+ [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
1641
+ duration_ms: (seconds * 1000).round }]
1641
1642
  end
1642
1643
  LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1643
1644
  end
1644
1645
  private :stopped_before_model
1645
1646
 
1647
+ # Who stopped a turn a hook stopped or cut (the controller's detail: the
1648
+ # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
1649
+ # nil for any other cancel.
1650
+ def cancelled_by(turn)
1651
+ return nil unless turn.controller.reason == :hook
1652
+
1653
+ detail = turn.controller.detail
1654
+ by = detail[:by].to_s if detail.is_a?(Hash)
1655
+ by unless by.nil? || by.empty?
1656
+ end
1657
+ private :cancelled_by
1658
+
1646
1659
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
1647
1660
  # sampling and thinking, the thinking resolved before the hooks run.
1648
1661
  # The model name is added in #generate, after the hooks that may switch
@@ -1744,7 +1757,7 @@ module Samagotchi
1744
1757
  end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
1745
1758
  kept = kept_messages(turn, result, seconds)
1746
1759
  if canceled
1747
- [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
1760
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
1748
1761
  duration_ms: (seconds * 1000).round }]
1749
1762
  else
1750
1763
  # For a client that attaches later (session_state_snapshot).
@@ -2298,6 +2311,7 @@ module Samagotchi
2298
2311
  # not an event of its own.
2299
2312
  next thinking_refused(event) if event[:type] == :thinking_refused
2300
2313
 
2314
+ event = with_generation_report(event) if event[:type] == :generation_completed
2301
2315
  emit_event(on_event, event)
2302
2316
  watch.feed(**progress) if watch && progress
2303
2317
  if event[:type] == :generation_completed
@@ -2307,6 +2321,19 @@ module Samagotchi
2307
2321
  end
2308
2322
  end
2309
2323
 
2324
+ # A :generation_completed with the generation's decode speed (`speed:`
2325
+ # {decode_tps:, source:}, nil without one) and the session's running
2326
+ # token totals (`tokens:`, the snapshot's block): the web's info bar
2327
+ # reads them from the stream. SessionMetrics closes the generation here,
2328
+ # so the event it gets next finds it closed.
2329
+ def with_generation_report(event)
2330
+ report = @metrics.finish_generation
2331
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens)
2332
+ rescue StandardError
2333
+ event
2334
+ end
2335
+ private :with_generation_report
2336
+
2310
2337
  # The turn's StreamWatch, or nil when no hook listens.
2311
2338
  def stream_watch(cancel_controller)
2312
2339
  return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
@@ -2322,8 +2349,8 @@ module Samagotchi
2322
2349
  rescue StandardError
2323
2350
  nil
2324
2351
  end
2325
- # Turn-scoped sink: receives the original event hash (no event_seq),
2326
- # byte-for-byte unchanged. Sink errors are isolated and never break the
2352
+ # Turn-scoped sink: receives the event hash as the stream handler
2353
+ # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
2327
2354
  # kernel loop (same as KernelLoop's own handling).
2328
2355
  if on_event
2329
2356
  begin
@@ -13,7 +13,7 @@ module Samagotchi
13
13
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
14
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
15
  # joining UI replays as live events.
16
- PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
16
+ PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
17
17
  # Types no Engine emits that a UI handles: the Bridge's stream frames
18
18
  # and the web's one synthetic replay event (a prompt merged into the
19
19
  # turn, turn_events.js snapshotEvents).
@@ -279,7 +279,8 @@ module Samagotchi
279
279
  @failures.add("rules in config.yml", e.message, required: true, group: :rules)
280
280
  end
281
281
  Guardrails::Rules.new(rules + bundle_rules, disable: disable,
282
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
282
+ enabled: Samagotchi::Config.get("guardrails.enabled") != false,
283
+ mode: Samagotchi::Config.get("guardrails.mode") || "auto")
283
284
  end
284
285
 
285
286
  # The dry-run diff of an edit/write call for its approval; a preview
@@ -44,6 +44,8 @@ module Samagotchi
44
44
  args: args_for_card(targets),
45
45
  cwd: targets&.cwd,
46
46
  repo_root: targets&.repo_root,
47
+ # The repository's name (a worktree's main checkout's folder).
48
+ repo_name: targets&.repo_name,
47
49
  branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
48
50
  rule: verdict.rule,
49
51
  source: verdict.source,
@@ -92,7 +94,7 @@ module Samagotchi
92
94
  end
93
95
 
94
96
  def label(scope, verdict)
95
- place = verdict.targets&.repo_root ? "in this repo" : "in this directory"
97
+ place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
96
98
  case scope
97
99
  when "once" then "Allow once"
98
100
  when "session" then "Allow this call for the session"
@@ -129,9 +131,8 @@ module Samagotchi
129
131
  what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
130
132
  lines = ["#{tool}: #{what}"]
131
133
  if targets
132
- repo = targets.repo_root
133
134
  branch = verdict.context&.branch(targets.cwd)
134
- where = repo ? "repo #{File.basename(repo)}#{", branch #{branch}" if branch}" : "not in a repo"
135
+ where = targets.repo_root ? "repo #{targets.repo_name}#{", branch #{branch}" if branch}" : "not in a repo"
135
136
  lines << " in #{targets.cwd} (#{where})"
136
137
  end
137
138
  who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
@@ -16,6 +16,10 @@ module Samagotchi
16
16
  # this session;
17
17
  # repo — this exact call in this repo (the cwd outside a repo);
18
18
  # rule — anything this rule (from this source) asks about here.
19
+ # "This repo" is the repository (repo: its common git dir), so an
20
+ # approval holds in every worktree of it; repo_root, the worktree it
21
+ # was given in, is kept for showing, and an entry from before repo:
22
+ # still matches that exact folder.
19
23
  # "once" is never stored. A file that doesn't parse is moved aside to
20
24
  # approvals.json.corrupt-<UTC time>[-N], with a warning, and the store
21
25
  # starts empty: it only means more asks, and nothing is overwritten.
@@ -62,13 +66,14 @@ module Samagotchi
62
66
  def self.find_in(list, verdict)
63
67
  key = key_for(verdict)
64
68
  place = place_for(verdict)
69
+ repo = verdict.targets&.repo
70
+ here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
65
71
  session_id = verdict.context&.session_id
66
72
  list.find do |e|
67
73
  case e["scope"]
68
74
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
69
- when "repo" then e["repo_root"] == place && e["key"] == key
70
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
71
- e["repo_root"] == place
75
+ when "repo" then here.(e) && e["key"] == key
76
+ when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.(e)
72
77
  end
73
78
  end
74
79
  end
@@ -81,8 +86,9 @@ module Samagotchi
81
86
  "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
82
87
  case scope
83
88
  when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
84
- when "repo" then entry.merge!("repo_root" => place_for(verdict), "key" => key_for(verdict))
85
- when "rule" then entry["repo_root"] = place_for(verdict)
89
+ when "repo"
90
+ entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
91
+ when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
86
92
  end
87
93
  entry.compact
88
94
  end
@@ -24,6 +24,11 @@ module Samagotchi
24
24
  end
25
25
 
26
26
  def repo_root(dir = @cwd) = @git.root(dir)
27
+ # The git hooks dir of +dir+'s repo (core.hooksPath honoured), or nil.
28
+ def hooks_dir(dir = @cwd) = @git.hooks(dir)
29
+ # +dir+'s repository: its absolute common git dir (<root>/.git for a
30
+ # plain checkout; the same for every worktree of it), or nil.
31
+ def repo(dir = @cwd) = @git.common_dir(dir)
27
32
  def branch(dir = @cwd) = @git.branch(dir)
28
33
 
29
34
  # The hook event's context: hash.
@@ -41,6 +46,8 @@ module Samagotchi
41
46
  end
42
47
 
43
48
  def root(dir) = info(dir)[:root]
49
+ def hooks(dir) = info(dir)[:hooks]
50
+ def common_dir(dir) = info(dir)[:common_dir]
44
51
  def branch(dir) = info(dir)[:branch]
45
52
 
46
53
  private
@@ -53,16 +60,16 @@ module Samagotchi
53
60
  def probe(dir)
54
61
  return {} unless File.directory?(dir)
55
62
 
56
- out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD")
57
- lines = out.lines.map(&:strip)
58
- # A repo with no commits yet has no HEAD: only the root answers.
59
- unless status.success?
60
- out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel")
61
- return {} unless status.success?
63
+ paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
64
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
65
+ # A repo with no commits yet has no HEAD: only the paths answer.
66
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
67
+ return {} unless status.success?
62
68
 
63
- lines = [out.strip]
64
- end
65
- { root: lines[0], branch: lines[1] }
69
+ lines = out.lines.map(&:strip)
70
+ # --git-path and --git-common-dir are relative to dir (core.hooksPath too).
71
+ { root: lines[0], hooks: lines[1] && File.expand_path(lines[1], dir),
72
+ common_dir: lines[2] && File.expand_path(lines[2], dir), branch: lines[3] }
66
73
  rescue SystemCallError
67
74
  {}
68
75
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../config"
4
4
  require_relative "../paths"
5
+ require_relative "../memory_paths"
5
6
  require_relative "../hooks/loader"
6
7
  require_relative "approval"
7
8
  require_relative "protected_paths"
@@ -42,9 +43,12 @@ module Samagotchi
42
43
  # Rules that ask about chi's own config and hooks: the core
43
44
  # ProtectedPaths asks and the guardrails bundle's shell rule.
44
45
  PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
45
- # A shell command naming chi's config, hooks or guardrails (the
46
- # guardrails bundle's shell-touches-chi, less .git/hooks).
46
+ # A shell command naming chi's config, hooks or guardrails as text
47
+ # (broad: a parent may not allow what only looks like one).
47
48
  CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
49
+ # The guardrails bundle's shell-touches-chi, for a word it can't
50
+ # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
+ CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
48
52
 
49
53
  module_function
50
54
 
@@ -101,10 +105,12 @@ module Samagotchi
101
105
  command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
102
106
  end
103
107
 
104
- # chi's config dir, the hooks dir and the approval store's dir.
108
+ # chi's config dir, the hooks dir, the approval store's dir and the
109
+ # installed bundles' dir.
105
110
  def chi_dirs
106
111
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
107
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
112
+ bundles = begin MemoryPaths.bundles_dir rescue nil end
113
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles]
108
114
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
109
115
  end
110
116