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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +31 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +68 -22
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +33 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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 =
|
|
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)}
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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 (`
|
|
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
|
-
|
|
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)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
|
31
|
-
completion_tokens = counts
|
|
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],
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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,
|
|
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
|
|
2326
|
-
#
|
|
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
|
data/lib/samagotchi/events.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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"
|
|
85
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
# A repo with no commits yet has no HEAD: only the
|
|
59
|
-
unless status.success?
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
{ root: lines[0],
|
|
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
|
|
46
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|