samagotchi 0.18.1 → 0.20.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  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/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -1
@@ -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
@@ -321,7 +321,8 @@ class SourceLinks
321
321
  links.any? do |link|
322
322
  in_target = start >= link[:target][0] && finish <= link[:target][1]
323
323
  in_label = start >= link[:label][0] && finish <= link[:label][1]
324
- in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish]))
324
+ in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish],
325
+ case_insensitive: match.regexp.casefold?))
325
326
  end
326
327
  end
327
328
 
@@ -330,10 +331,12 @@ class SourceLinks
330
331
  # the ref. So `[JIRA-1](…/JIRA-12)` is NOT skipped (the target names
331
332
  # JIRA-12), while `[JIRA-123](…/JIRA-123)` is. Note this is stricter than
332
333
  # the bare-text scan's `\b`, which treats `-` as a boundary: `…/JIRA-1-foo`
333
- # would match there but not here.
334
- def target_names_ref?(target, ref)
334
+ # would match there but not here. A case_insensitive source compares
335
+ # without case too: `[jira-1](…/JIRA-1)` is skipped.
336
+ def target_names_ref?(target, ref, case_insensitive: false)
335
337
  escaped = Regexp.escape(ref)
336
- target.match?(/(?<![A-Za-z0-9\-])#{escaped}(?![A-Za-z0-9\-])/)
338
+ flags = case_insensitive ? Regexp::IGNORECASE : 0
339
+ target.match?(Regexp.new("(?<![A-Za-z0-9\\-])#{escaped}(?![A-Za-z0-9\\-])", flags))
337
340
  end
338
341
 
339
342
  # A ref glued to URL punctuation is part of a link too, even when the span
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:574ea0c2b36f80dde410c4251753d70cbeea081d129f3f3547f41370e3990993
11
+ sha256: sha256:5a996b718b1db729305b893ed583b9e600e813570861d47e0b65a24fec36a35d
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.18.1
3
+ version: 0.20.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:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
10
+ memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -88,13 +88,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
88
88
  | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
89
89
  | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
90
90
  | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
91
- | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
91
+ | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
92
92
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
93
93
  | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
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],
@@ -169,9 +169,10 @@ module Samagotchi
169
169
  # kernel's dispatch): the built-ins, per Engine.
170
170
  @tools = Tools::Builtins.registry
171
171
  if @scratch
172
- # A scratch session's children would outlive it; its memories
173
- # would too (write and edit into the memories: ScratchWrites).
174
- [Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
172
+ # A scratch session's children would outlive it, and so would a
173
+ # note it left in a peer; its memories would too (write and edit
174
+ # into the memories: ScratchWrites).
175
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
175
176
  @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
176
177
  end
177
178
  # Likewise the slash commands its SessionCommands run.
@@ -614,6 +615,11 @@ module Samagotchi
614
615
  Thread.current[key] = outer
615
616
  end
616
617
 
618
+ # Whether an anytime command (/btw) still runs on its thread.
619
+ def anytime_running?
620
+ @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
621
+ end
622
+
617
623
  # Start an anytime command on its own thread (D8), one #shutdown waits
618
624
  # for, so its command_ran is announced before the process leaves.
619
625
  # @return [Thread]
@@ -2292,6 +2298,7 @@ module Samagotchi
2292
2298
  # not an event of its own.
2293
2299
  next thinking_refused(event) if event[:type] == :thinking_refused
2294
2300
 
2301
+ event = with_generation_report(event) if event[:type] == :generation_completed
2295
2302
  emit_event(on_event, event)
2296
2303
  watch.feed(**progress) if watch && progress
2297
2304
  if event[:type] == :generation_completed
@@ -2301,6 +2308,19 @@ module Samagotchi
2301
2308
  end
2302
2309
  end
2303
2310
 
2311
+ # A :generation_completed with the generation's decode speed (`speed:`
2312
+ # {decode_tps:, source:}, nil without one) and the session's running
2313
+ # token totals (`tokens:`, the snapshot's block): the web's info bar
2314
+ # reads them from the stream. SessionMetrics closes the generation here,
2315
+ # so the event it gets next finds it closed.
2316
+ def with_generation_report(event)
2317
+ report = @metrics.finish_generation
2318
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens)
2319
+ rescue StandardError
2320
+ event
2321
+ end
2322
+ private :with_generation_report
2323
+
2304
2324
  # The turn's StreamWatch, or nil when no hook listens.
2305
2325
  def stream_watch(cancel_controller)
2306
2326
  return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
@@ -2316,8 +2336,8 @@ module Samagotchi
2316
2336
  rescue StandardError
2317
2337
  nil
2318
2338
  end
2319
- # Turn-scoped sink: receives the original event hash (no event_seq),
2320
- # byte-for-byte unchanged. Sink errors are isolated and never break the
2339
+ # Turn-scoped sink: receives the event hash as the stream handler
2340
+ # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
2321
2341
  # kernel loop (same as KernelLoop's own handling).
2322
2342
  if on_event
2323
2343
  begin
@@ -70,10 +70,15 @@ module Samagotchi
70
70
  @interface = value
71
71
  end
72
72
 
73
+ # The approval scopes a scratch session offers: nothing outlives it.
74
+ SCRATCH_SCOPES = %w[once session].freeze
75
+
73
76
  # Where the approval store lives: beside Session's state dir
74
- # ($XDG_STATE_HOME/samagotchi/guardrails/).
77
+ # ($XDG_STATE_HOME/samagotchi/guardrails/). A scratch session reads
78
+ # it but keeps its own session approvals in memory.
75
79
  def state_dir=(state_dir)
76
80
  @approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
81
+ @approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
77
82
  @protected = nil
78
83
  end
79
84
 
@@ -207,7 +212,8 @@ module Samagotchi
207
212
  # A plugin tool is asked about by its label, as its row shows it.
208
213
  label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
209
214
  # verdict.call is the call that will run (a hook may have replaced it).
210
- payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
215
+ payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
216
+ only: (SCRATCH_SCOPES if @scratch))
211
217
  Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
212
218
  end
213
219
 
@@ -273,7 +279,8 @@ module Samagotchi
273
279
  @failures.add("rules in config.yml", e.message, required: true, group: :rules)
274
280
  end
275
281
  Guardrails::Rules.new(rules + bundle_rules, disable: disable,
276
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
282
+ enabled: Samagotchi::Config.get("guardrails.enabled") != false,
283
+ mode: Samagotchi::Config.get("guardrails.mode") || "auto")
277
284
  end
278
285
 
279
286
  # The dry-run diff of an edit/write call for its approval; a preview
@@ -22,9 +22,11 @@ module Samagotchi
22
22
  # approval[:tool] stays the raw name
23
23
  # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
24
  # write: the card shows its diff, the question text one change line
25
+ # @param only [Array<String>, nil] offer no scope beyond these (a
26
+ # scratch session: once, session); "once" when none is left
25
27
  # @return [Hash] open_question fields
26
- def payload(verdict, label: nil, preview: nil)
27
- scopes = offered_scopes(verdict)
28
+ def payload(verdict, label: nil, preview: nil, only: nil)
29
+ scopes = offered_scopes(verdict, only: only)
28
30
  targets = verdict.targets
29
31
  {
30
32
  question: question_text(verdict, label: label, preview: preview),
@@ -42,6 +44,8 @@ module Samagotchi
42
44
  args: args_for_card(targets),
43
45
  cwd: targets&.cwd,
44
46
  repo_root: targets&.repo_root,
47
+ # The repository's name (a worktree's main checkout's folder).
48
+ repo_name: targets&.repo_name,
45
49
  branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
46
50
  rule: verdict.rule,
47
51
  source: verdict.source,
@@ -81,12 +85,16 @@ module Samagotchi
81
85
  end
82
86
 
83
87
  # The rule's scopes; "rule" only when a rule id names what to approve.
84
- def offered_scopes(verdict)
85
- verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
88
+ def offered_scopes(verdict, only: nil)
89
+ scopes = verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
90
+ return scopes unless only
91
+
92
+ scopes &= only
93
+ scopes.empty? ? ["once"] : scopes
86
94
  end
87
95
 
88
96
  def label(scope, verdict)
89
- 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"
90
98
  case scope
91
99
  when "once" then "Allow once"
92
100
  when "session" then "Allow this call for the session"
@@ -123,9 +131,8 @@ module Samagotchi
123
131
  what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
124
132
  lines = ["#{tool}: #{what}"]
125
133
  if targets
126
- repo = targets.repo_root
127
134
  branch = verdict.context&.branch(targets.cwd)
128
- 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"
129
136
  lines << " in #{targets.cwd} (#{where})"
130
137
  end
131
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.
@@ -45,34 +49,48 @@ module Samagotchi
45
49
  # @param verdict [Verdict] an ask with its targets and context
46
50
  # @return [Hash, nil] the first entry that allows it
47
51
  def match(verdict)
48
- key = self.class.key_for(verdict)
49
- place = self.class.place_for(verdict)
52
+ self.class.find_in(read, verdict)
53
+ end
54
+
55
+ # Store the verdict's allowed scope (not "once").
56
+ # @return [Hash, nil] the entry
57
+ def add(verdict, scope)
58
+ entry = self.class.entry_for(verdict, scope) or return nil
59
+
60
+ update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
61
+ entry
62
+ end
63
+
64
+ # @param list [Array<Hash>] entries
65
+ # @return [Hash, nil] the first of +list+ that allows +verdict+
66
+ def self.find_in(list, verdict)
67
+ key = key_for(verdict)
68
+ place = place_for(verdict)
69
+ repo = verdict.targets&.repo
70
+ here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
50
71
  session_id = verdict.context&.session_id
51
- read.find do |e|
72
+ list.find do |e|
52
73
  case e["scope"]
53
74
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
54
- when "repo" then e["repo_root"] == place && e["key"] == key
55
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
56
- 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)
57
77
  end
58
78
  end
59
79
  end
60
80
 
61
- # Store the verdict's allowed scope (not "once").
62
- # @return [Hash, nil] the entry
63
- def add(verdict, scope)
81
+ # @return [Hash, nil] the entry that stores +scope+ for +verdict+; nil for "once"
82
+ def self.entry_for(verdict, scope)
64
83
  return nil unless STORED_SCOPES.include?(scope)
65
84
 
66
85
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
67
86
  "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
68
87
  case scope
69
- when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => self.class.key_for(verdict))
70
- when "repo" then entry.merge!("repo_root" => self.class.place_for(verdict), "key" => self.class.key_for(verdict))
71
- when "rule" then entry["repo_root"] = self.class.place_for(verdict)
88
+ when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_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)
72
92
  end
73
- entry.compact!
74
- update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
75
- entry
93
+ entry.compact
76
94
  end
77
95
 
78
96
  # Remove entry +index+ (0-based, as #entries lists them).
@@ -167,6 +185,42 @@ module Samagotchi
167
185
  AtomicFile.write(@path, JSON.pretty_generate(list))
168
186
  end
169
187
  end
188
+
189
+ # A scratch session's approvals: the store's entries still apply, and
190
+ # its own session approvals are kept in memory, so none outlives it
191
+ # (it offers no wider scope; one that comes anyway is not kept).
192
+ class InMemory
193
+ def initialize(store)
194
+ @store = store
195
+ @own = []
196
+ @mutex = Mutex.new
197
+ end
198
+
199
+ def path = @store.path
200
+
201
+ # The store's entries, then this session's own.
202
+ def entries = @store.entries + @mutex.synchronize { @own.dup }
203
+
204
+ def match(verdict)
205
+ @mutex.synchronize { Approvals.find_in(@own, verdict) } || @store.match(verdict)
206
+ end
207
+
208
+ def add(verdict, scope)
209
+ return nil unless scope == "session"
210
+
211
+ entry = Approvals.entry_for(verdict, scope)
212
+ @mutex.synchronize { @own << entry unless @own.any? { |e| e.except("created_at") == entry.except("created_at") } }
213
+ entry
214
+ end
215
+
216
+ # Index as #entries lists them: the store's first, then its own.
217
+ def revoke(index)
218
+ stored = @store.entries.size
219
+ return @store.revoke(index) if index < stored
220
+
221
+ @mutex.synchronize { index - stored < @own.size ? @own.delete_at(index - stored) : nil }
222
+ end
223
+ end
170
224
  end
171
225
  end
172
226
  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