samagotchi 0.8.0 → 0.9.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/docs/configuration.md +1 -0
  4. data/docs/guardrails.md +48 -2
  5. data/docs/memory.md +5 -3
  6. data/docs/plugins.md +13 -5
  7. data/docs/releasing.md +1 -1
  8. data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
  9. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
  10. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  11. data/lib/samagotchi/bundles/skills/plugin.rb +61 -11
  12. data/lib/samagotchi/bundles/system/identity.md +1 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  15. data/lib/samagotchi/config.rb +3 -0
  16. data/lib/samagotchi/engine.rb +13 -0
  17. data/lib/samagotchi/guardrail_wiring.rb +4 -1
  18. data/lib/samagotchi/guardrails/gate.rb +7 -4
  19. data/lib/samagotchi/guardrails/model_size.rb +87 -0
  20. data/lib/samagotchi/guardrails/rules.rb +35 -3
  21. data/lib/samagotchi/guardrails/targets.rb +29 -5
  22. data/lib/samagotchi/guardrails.rb +1 -0
  23. data/lib/samagotchi/log_subscriber.rb +5 -1
  24. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  25. data/lib/samagotchi/memory_bundle/installer.rb +24 -3
  26. data/lib/samagotchi/session_commands.rb +6 -1
  27. data/lib/samagotchi/session_manager.rb +2 -2
  28. data/lib/samagotchi/session_metrics.rb +2 -1
  29. data/lib/samagotchi/sessions_command.rb +3 -2
  30. data/lib/samagotchi/system_prompt.rb +2 -1
  31. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  32. data/lib/samagotchi/tool_declarations.rb +1 -1
  33. data/lib/samagotchi/tool_runner.rb +20 -3
  34. data/lib/samagotchi/tools/memory.rb +1 -2
  35. data/lib/samagotchi/version.rb +1 -1
  36. metadata +4 -1
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+
5
+ module Samagotchi
6
+ module Guardrails
7
+ # Whether the effective model is a small one, for rules with
8
+ # `models: small` (guardrails.small_models). Nothing reports a model's
9
+ # size (llama.cpp's /props has no parameter count), so `auto` reads it
10
+ # from the bare model name: Qwen3.6-27B → 27, gemma-4-E4B-it → 4,
11
+ # Ornith-1.5-35B-A3B → 3 (an MoE's active size counts), Mixtral-8x7B
12
+ # → 56. A name without a size, and no model at all, is not small (fail
13
+ # open: the rules for small models are extra asks, not the defaults).
14
+ module ModelSize
15
+ SETTING = "guardrails.small_models"
16
+ # The largest auto-small size, in billions of parameters.
17
+ MAX_SMALL = 32
18
+ GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
19
+
20
+ # A size starts at the name's start or after a separator, and ends at
21
+ # a separator or the name's end (so 4bit and v4.1 aren't sizes).
22
+ NUM = '(\d+(?:\.\d+)?)'
23
+ BEFORE = '(?:\A|[-_/:\s])'
24
+ AFTER = '(?=[-_:\s]|\z)'
25
+ ACTIVE = /#{BEFORE}a#{NUM}b#{AFTER}/i
26
+ EXPERTS = /#{BEFORE}(\d+)x#{NUM}b#{AFTER}/i
27
+ PLAIN = /#{BEFORE}e?#{NUM}b#{AFTER}/i
28
+
29
+ module_function
30
+
31
+ # The size the bare model name gives, in billions; nil without one.
32
+ # @return [Numeric, nil]
33
+ def billions(name)
34
+ name = name.to_s
35
+ if (m = name.match(ACTIVE))
36
+ number(m[1])
37
+ elsif (m = name.match(EXPERTS))
38
+ m[1].to_i * number(m[2])
39
+ elsif (m = name.match(PLAIN))
40
+ number(m[1])
41
+ end
42
+ end
43
+
44
+ def number(text)
45
+ text.include?(".") ? text.to_f : text.to_i
46
+ end
47
+
48
+ # @param name [String, nil] the bare model name (no host prefix)
49
+ # @param key [String, nil] its model key (ModelOverlay.key_for)
50
+ # @param setting [String, nil] guardrails.small_models: "auto", globs
51
+ # on the name or the key, "|"-separated ("auto" may be one of them),
52
+ # or "" (never)
53
+ def small?(name, key, setting = self.setting)
54
+ return false if name.nil? || name.to_s.strip.empty?
55
+
56
+ entries = (setting.nil? ? "auto" : setting.to_s).split("|").map(&:strip).reject(&:empty?)
57
+ entries.any? do |entry|
58
+ if entry.casecmp?("auto")
59
+ (size = billions(name)) ? size <= MAX_SMALL : false
60
+ else
61
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
62
+ end
63
+ end
64
+ end
65
+
66
+ # How /guardrails says it: "small (auto, 27B)", "not small (auto, no
67
+ # size in the name)", "small (small_models: qwen*)".
68
+ def describe(name, key, setting = self.setting)
69
+ return "not small (no model name)" if name.nil? || name.to_s.strip.empty?
70
+
71
+ verdict = small?(name, key, setting) ? "small" : "not small"
72
+ return "#{verdict} (small_models: #{setting.empty? ? "[]" : setting})" unless setting.nil? || setting.strip.casecmp?("auto")
73
+
74
+ size = billions(name)
75
+ "#{verdict} (auto, #{size ? "#{size}B" : "no size in the name"})"
76
+ end
77
+
78
+ # The setting, live (config.yml is re-read on change); auto when it
79
+ # can't be read.
80
+ def setting
81
+ Config.get(SETTING)
82
+ rescue StandardError
83
+ "auto"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "verdict"
4
4
  require_relative "targets"
5
+ require_relative "model_size"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -13,6 +14,10 @@ module Samagotchi
13
14
  # path: "outside_repo", or a glob on the resolved paths (absolute
14
15
  # or "**/…" globs match the absolute path; others the path
15
16
  # relative to the repo root, the cwd outside one)
17
+ # models: "small" (Guardrails::ModelSize, guardrails.small_models),
18
+ # or a glob on the bare model name or the model key, or a
19
+ # list of them: the rule only votes for a model that matches
20
+ # (no model matches none; missing = every model)
16
21
  # verdict ask|deny, reason, scopes (for an ask; default all).
17
22
  # `guardrails.disable` switches single rules off (#parse_disable).
18
23
  # A rule that doesn't parse raises ParseError: the Engine then denies
@@ -21,13 +26,14 @@ module Samagotchi
21
26
  class Rules
22
27
  class ParseError < StandardError; end
23
28
 
24
- KEYS = %w[id tool command path verdict reason scopes].freeze
29
+ KEYS = %w[id tool command path models verdict reason scopes].freeze
25
30
  VERDICTS = %w[ask deny].freeze
26
31
  GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
27
32
 
28
- Rule = Struct.new(:id, :tools, :command, :path, :verdict, :reason, :scopes, :source, keyword_init: true) do
33
+ Rule = Struct.new(:id, :tools, :command, :path, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
29
34
  def matches?(targets)
30
35
  return false unless targets
36
+ return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
31
37
  return false if tools && !tool_matches?(targets.tool)
32
38
  return false if command && !(targets.command && command.match?(targets.command))
33
39
  return false if path && !path_matches?(targets)
@@ -39,6 +45,21 @@ module Samagotchi
39
45
  tools.any? { |tool| Rules.glob?(tool) ? File.fnmatch(tool, name.to_s, File::FNM_EXTGLOB) : tool == name }
40
46
  end
41
47
 
48
+ # Whether the rule votes for this model (its models:; without one,
49
+ # for every model). +small+ says whether the model is a small one.
50
+ def for_model?(name, key, small = -> { ModelSize.small?(name, key) })
51
+ return true unless models
52
+ return false if name.nil? || name.empty?
53
+
54
+ models.any? do |entry|
55
+ if entry == "small"
56
+ small.call
57
+ else
58
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate, ModelSize::GLOB_FLAGS) }
59
+ end
60
+ end
61
+ end
62
+
42
63
  def path_matches?(targets)
43
64
  return targets.outside_repo? if path == "outside_repo"
44
65
 
@@ -83,7 +104,7 @@ module Samagotchi
83
104
  end
84
105
 
85
106
  Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
86
- path: path_of(raw["path"], label), verdict: verdict.to_sym,
107
+ path: path_of(raw["path"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
87
108
  reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
88
109
  end
89
110
 
@@ -114,6 +135,17 @@ module Samagotchi
114
135
  value
115
136
  end
116
137
 
138
+ def self.models_of(value, label)
139
+ return nil if value.nil?
140
+
141
+ names = Array(value)
142
+ unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? }
143
+ raise ParseError, "#{label}: models must be small, a glob or a list of them"
144
+ end
145
+
146
+ names.map(&:strip)
147
+ end
148
+
117
149
  def self.scopes_of(value, label)
118
150
  return nil if value.nil?
119
151
 
@@ -3,6 +3,8 @@
3
3
  require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../log"
6
+ require_relative "model_size"
7
+ require_relative "../memory_bundle/index_sync"
6
8
 
7
9
  module Samagotchi
8
10
  module Guardrails
@@ -16,6 +18,10 @@ module Samagotchi
16
18
 
17
19
  attr_reader :tool, :command, :paths, :cwd, :repo_root
18
20
 
21
+ # @return [String, nil] the effective model: its bare name (no host
22
+ # prefix) and its key (ModelOverlay.key_for), for a rule's models:
23
+ attr_reader :model_name, :model_key
24
+
19
25
  # @return [Hash, nil] a plugin tool's arguments (call[:args]), for an
20
26
  # approval of a call whose targets name no command or path; nil for
21
27
  # chi's own tools
@@ -23,10 +29,12 @@ module Samagotchi
23
29
 
24
30
  # @param call [Hash] the parsed tool call
25
31
  # @param context [Context]
26
- # @param model_key [String, nil] for a memory_write model overlay
32
+ # @param model_key [String, nil] for a memory_write model overlay, and
33
+ # a rule's models:
34
+ # @param model_name [String, nil] the bare model name, for a rule's models:
27
35
  # @param registry [Tools::Registry, nil] the session's tools: a plugin
28
36
  # tool's entry says what it acts on (its targets:)
29
- def self.for(call, context, model_key: nil, registry: nil)
37
+ def self.for(call, context, model_key: nil, model_name: nil, registry: nil)
30
38
  tool = call[:name].to_s
31
39
  base = context.cwd
32
40
  command = nil
@@ -53,7 +61,8 @@ module Samagotchi
53
61
  entry = registry && registry[tool]
54
62
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
55
63
  end
56
- new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args)
64
+ new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
65
+ model_name: model_name, model_key: model_key)
57
66
  end
58
67
 
59
68
  # What a plugin tool's targets: callable says the call acts on:
@@ -93,8 +102,10 @@ module Samagotchi
93
102
  nil
94
103
  end
95
104
 
96
- def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil)
105
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil)
97
106
  @args = args
107
+ @model_name = model_name
108
+ @model_key = model_key
98
109
  @tool = tool
99
110
  @command = command
100
111
  @paths = paths
@@ -104,10 +115,23 @@ module Samagotchi
104
115
 
105
116
  def shell? = SHELL_TOOLS.include?(@tool)
106
117
 
118
+ # Whether the effective model is a small one (guardrails.small_models,
119
+ # read when first asked: once per call's targets).
120
+ def small_model?
121
+ return @small_model if defined?(@small_model)
122
+
123
+ @small_model = ModelSize.small?(@model_name, @model_key)
124
+ end
125
+
107
126
  # Whether any path is outside the repo root (the cwd outside a repo).
127
+ # A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
128
+ # write/edit there is what memory_write does, unasked; index.md, the
129
+ # bundles dir and the rest of chi's config dir still count.
108
130
  def outside_repo?
109
131
  root = @repo_root || @cwd
110
- @paths.any? { |p| p != root && !p.start_with?(File.join(root, "")) }
132
+ @paths.any? do |p|
133
+ p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
134
+ end
111
135
  end
112
136
 
113
137
  # The hook event's targets: hash.
@@ -3,6 +3,7 @@
3
3
  require_relative "guardrails/verdict"
4
4
  require_relative "guardrails/context"
5
5
  require_relative "guardrails/targets"
6
+ require_relative "guardrails/model_size"
6
7
  require_relative "guardrails/approval"
7
8
  require_relative "guardrails/approvals"
8
9
  require_relative "guardrails/protected_paths"
@@ -123,8 +123,12 @@ module Samagotchi
123
123
  def on_tool_call_completed(event)
124
124
  # A tool's output can be any bytes (invalid UTF-8 would fail the match).
125
125
  output = event[:output].to_s.scrub
126
+ # ms leaves out a guardrail approval wait (waited_ms, logged apart).
127
+ waited = event[:waited_ms].to_i
128
+ ms = since(@tool_started_at.delete([event[:iteration], event[:call_index]]))
129
+ ms = [ms - waited, 0].max if ms
126
130
  log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
127
- ms: since(@tool_started_at.delete([event[:iteration], event[:call_index]])),
131
+ ms: ms, waited_ms: (waited if waited.positive?),
128
132
  output_chars: output.length, truncated: event[:output_truncated] || nil,
129
133
  error: tool_error?(output) || nil)
130
134
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index_updater"
4
+ require_relative "../tools/memory"
5
+ require_relative "../model_overlay"
6
+ require_relative "../log"
7
+
8
+ module Samagotchi
9
+ module MemoryBundle
10
+ # Keeps a memory's managed index.md line current when the file tools
11
+ # (write, edit) change it, as memory_write does: the ToolRunner calls
12
+ # .refresh after a write/edit that changed its file (one it could diff:
13
+ # a memory over 1 MB isn't refreshed). The line's description is kept.
14
+ #
15
+ # Only a *.md right in the project or system memories dir (the dirs
16
+ # memory_write writes to) counts: not index.md, not a hidden file, not a
17
+ # model overlay (<name>.<key>.md next to its <name>.md; a memory whose
18
+ # name has a dot and a sibling of the same stem reads as an overlay too,
19
+ # a leftover ambiguity). Writes into another project's folder and
20
+ # execute's writes aren't seen.
21
+ module IndexSync
22
+ SCOPES = %w[project system].freeze
23
+
24
+ # @param path [String] the file write/edit changed
25
+ # @return [Boolean] whether an index line was written
26
+ def self.refresh(path)
27
+ path = File.expand_path(path.to_s)
28
+ scope = memory_scope(path)
29
+ return false unless scope && File.file?(path)
30
+
31
+ name = File.basename(path, ".md")
32
+ return false if overlay?(path, name)
33
+
34
+ IndexUpdater.update_index(scope, name, File.size(path), nil)
35
+ rescue StandardError => e
36
+ Log.warn(:memory, "index_sync_failed", path: path, error: "#{e.class}: #{e.message}")
37
+ false
38
+ end
39
+
40
+ # "project" or "system" when +path+ is a memory's file (or an
41
+ # overlay's): a *.md right in that scope's memories dir, not index.md,
42
+ # not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
43
+ # uses it too: write/edit there is what memory_write does. A symlink
44
+ # isn't: write/edit follow it to wherever it points, memory_write
45
+ # replaces the link.
46
+ def self.memory_scope(path)
47
+ path = File.expand_path(path.to_s)
48
+ name = File.basename(path, ".md")
49
+ return nil unless File.extname(path) == ".md"
50
+ return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
51
+
52
+ dir = real(File.dirname(path))
53
+ SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def self.overlay?(path, name)
59
+ match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
60
+ return false unless match
61
+
62
+ base = name.delete_suffix(".#{match[1]}")
63
+ File.file?(File.join(File.dirname(path), "#{base}.md"))
64
+ end
65
+
66
+ def self.real(dir)
67
+ File.realpath(dir)
68
+ rescue SystemCallError
69
+ File.expand_path(dir)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -86,6 +86,8 @@ module Samagotchi
86
86
  all_files_in_bundle = []
87
87
  provenance = Provenance.new(name: @name)
88
88
  existing_provenance = provenance.read
89
+ # A plain install over an installed bundle: summary points at upgrade.
90
+ @reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
89
91
 
90
92
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
91
93
  file_key = File.basename(file_path)
@@ -125,12 +127,17 @@ module Samagotchi
125
127
  classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
126
128
  @results[file_key] = { status: classification.to_s }
127
129
  @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
130
+ elsif FileUtils.identical?(file_path, target_path)
131
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
128
132
  else
129
133
  @results[file_key] = { status: "would_skip" }
130
134
  end
131
135
  else
132
136
  @results[file_key] = { status: "would_install" }
133
137
  end
138
+ elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
139
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
140
+ update_target_index(target_scope, target_path, file_key)
134
141
  elsif File.exist?(target_path) && !@force
135
142
  @results[file_key] = { status: "skipped", reason: "already exists" }
136
143
  @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
@@ -252,13 +259,19 @@ module Samagotchi
252
259
  if @dry_run
253
260
  incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
254
261
  else
262
+ # Same bytes as installed: reported as up to date, not "Installed".
263
+ unchanged = incoming_rules.select do |src|
264
+ dest = File.join(guardrails_target, File.basename(src))
265
+ File.file?(dest) && FileUtils.identical?(src, dest)
266
+ end
255
267
  FileUtils.rm_rf(guardrails_target)
256
268
  unless incoming_rules.empty?
257
269
  FileUtils.mkdir_p(guardrails_target)
258
270
  incoming_rules.each do |src|
259
271
  dest = File.join(guardrails_target, File.basename(src))
260
272
  FileUtils.cp(src, dest)
261
- @results["guardrails/#{File.basename(src)}"] = { status: "installed" }
273
+ @results["guardrails/#{File.basename(src)}"] =
274
+ unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
262
275
  guardrail_files_for_provenance[File.basename(src)] = dest
263
276
  end
264
277
  end
@@ -278,7 +291,8 @@ module Samagotchi
278
291
  all_files_in_bundle.each do |file_key|
279
292
  next if @conflicts.key?(file_key)
280
293
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
281
- next if %w[kept kept_pruned fast_forward updated].include?(status)
294
+ # skipped: not copied, so a local edit isn't a bad download.
295
+ next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
282
296
  # For dry-run, fast_forward would appear as "fast_forward", updated not yet
283
297
  target_path = File.join(target_dir, file_key)
284
298
  next unless File.exist?(target_path)
@@ -340,7 +354,13 @@ module Samagotchi
340
354
  else
341
355
  all_files_in_bundle.each do |file_key|
342
356
  target_path = File.join(target_dir, file_key)
343
- provenance_files[file_key] = target_path if File.exist?(target_path)
357
+ next unless File.exist?(target_path)
358
+
359
+ # Skipped for a local edit: keep the installed base, so the
360
+ # next upgrade still sees the edit (not a fast-forward over it).
361
+ base_path = provenance.base_path(file_key)
362
+ kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
363
+ provenance_files[file_key] = kept ? base_path : target_path
344
364
  end
345
365
  end
346
366
  # Build hooks metadata for provenance
@@ -409,6 +429,7 @@ module Samagotchi
409
429
  lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
410
430
  lines.concat(@warnings) unless @warnings.empty?
411
431
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
432
+ lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
412
433
 
413
434
  lines.any? ? lines.join("\n") : "No files to install."
414
435
  end
@@ -234,6 +234,9 @@ module Samagotchi
234
234
  rules = @engine.guardrail_rules
235
235
  approvals = @engine.guardrail_approvals.entries
236
236
  lines = ["guardrails: #{rules.enabled? ? "on" : "off (guardrails.enabled: false; denies still apply)"}"]
237
+ name = @engine.guardrail_model_name
238
+ key = @engine.model_key
239
+ lines << "model: #{name || "none"} — #{Guardrails::ModelSize.describe(name, key)}"
237
240
  failures = @engine.guardrail_failures.list
238
241
  unless failures.empty?
239
242
  lines << "failed to load:"
@@ -248,9 +251,11 @@ module Samagotchi
248
251
  match = [
249
252
  ("tool #{rule.tools.join(",")}" if rule.tools),
250
253
  ("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
251
- ("path #{rule.path}" if rule.path)
254
+ ("path #{rule.path}" if rule.path),
255
+ ("models #{rule.models.join(",")}" if rule.models)
252
256
  ].compact.join(", ")
253
257
  off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
258
+ off ||= "off for this model — " unless rule.for_model?(name, key)
254
259
  lines << " #{idx + 1}. #{rule.id}: #{off}#{rule.verdict} (#{match}) — #{rule.reason} [#{rule.source}]"
255
260
  end
256
261
  rules.unmatched_disables.each { |entry| lines << " guardrails.disable: #{entry} matches no rule" }
@@ -224,11 +224,11 @@ module Samagotchi
224
224
  # a turn running, recap = the saved recap's first sentence, project =
225
225
  # Session#project_root
226
226
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
227
- project_root: nil, include_archived: false)
227
+ project_root: nil, include_archived: false, sort: nil, order: nil)
228
228
  sd = state_dir || Session.default_state_dir
229
229
  root = cwd && folder_path(cwd)
230
230
  roots = {}
231
- summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root,
231
+ summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
232
232
  include_archived: include_archived).lazy
233
233
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
234
234
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
@@ -427,7 +427,8 @@ module Samagotchi
427
427
  ).merge(
428
428
  status: status.empty? ? "ok" : status,
429
429
  completed_at: finished_at.iso8601(3),
430
- duration_ms: elapsed_ms(active[:started_monotonic])
430
+ # Less a guardrail approval wait (ToolRunner's waited_ms).
431
+ duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
431
432
  )
432
433
  end
433
434
  end
@@ -120,11 +120,12 @@ module Samagotchi
120
120
  end
121
121
 
122
122
  def list_summaries(project, scope_note)
123
- live, cwd, limit, include_archived, format = @opts.values_at(:live, :cwd, :limit, :include_archived, :format)
123
+ live, cwd, limit, include_archived, format, sort, order =
124
+ @opts.values_at(:live, :cwd, :limit, :include_archived, :format, :sort, :order)
124
125
  summaries = Samagotchi::SessionManager.session_summaries(
125
126
  live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
126
127
  project_root: project,
127
- include_archived: include_archived
128
+ include_archived: include_archived, sort: sort, order: order
128
129
  )
129
130
  case format
130
131
  when "json"
@@ -175,11 +175,12 @@ module Samagotchi
175
175
  System scope: ~/.config/samagotchi/memories/ (cross-project)
176
176
  memory_read accepts optional scope (project|system).
177
177
  memory_write requires explicit scope and entry name.
178
+ For a small change to an existing memory, `edit` its file (<scope dir>/<name>.md)
179
+ instead of rewriting it all with memory_write; its index line is refreshed either way.
178
180
  User prompts may contain memory shorthand like #entry_name.
179
181
  Treat #entry_name as a memory reference, not as a file path.
180
182
  If shorthand includes a scope prefix, such as #project/entry_name or #system/entry_name,
181
183
  preserve that scope when reading the memory.
182
- Keep each scope's index.md updated when adding/updating entries.
183
184
  Each scope's `index.md` is auto-maintained by `memory_write` (one
184
185
  managed line per entry with name/scope/date/size); free-form sections
185
186
  are preserved. The verbatim `index` write (`name: "index"`) is kept.
@@ -209,9 +209,11 @@ private
209
209
  [event[:iteration].to_i, event[:call_index].to_i, event[:tool].to_s]
210
210
  end
211
211
 
212
+ # From tool_call_started, less an approval wait (waited_ms): an asked
213
+ # row counts from the answer.
212
214
  def tool_duration_ms(event)
213
215
  started_at = @tool_started_at.delete(tool_call_key(event))
214
- started_at && ((@clock.call - started_at) * 1000.0)
216
+ started_at && [((@clock.call - started_at) * 1000.0) - event[:waited_ms].to_f, 0.0].max
215
217
  end
216
218
  end
217
219
  end
@@ -123,7 +123,7 @@ module Samagotchi
123
123
  },
124
124
  {
125
125
  name: "memory_write",
126
- description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
126
+ description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. For a small change to an existing memory (a step, a line), `edit` its file (<scope dir>/<name>.md) instead of rewriting it all; its index line is refreshed either way. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
127
127
  parameters: {
128
128
  type: "object",
129
129
  properties: {
@@ -6,6 +6,7 @@ require_relative "vision_context"
6
6
  require_relative "log"
7
7
  require_relative "edit_preview"
8
8
  require_relative "tools/tool_path"
9
+ require_relative "memory_bundle/index_sync"
9
10
 
10
11
  module Samagotchi
11
12
  # The single per-call path both loops use: the tool_call_started and
@@ -25,8 +26,10 @@ module Samagotchi
25
26
 
26
27
  # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
27
28
  # the kernel is built.
28
- def initialize(kernel)
29
+ # @param clock [#call] monotonic seconds, for the approval wait
30
+ def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
29
31
  @kernel = kernel
32
+ @clock = clock
30
33
  end
31
34
 
32
35
  # @param call_index [Integer] 1-based position of the call in its batch
@@ -54,11 +57,18 @@ module Samagotchi
54
57
  emit(on_stream_event, started)
55
58
 
56
59
  # The ask comes after tool_call_started: the UI shows the tool line,
57
- # then the approval under it.
58
- settle_ask(verdict) if verdict.ask?
60
+ # then the approval under it. waited_ms on tool_call_completed lets a
61
+ # UI that times the row from tool_call_started leave the wait out.
62
+ waited_ms = nil
63
+ if verdict.ask?
64
+ asked_at = @clock.call
65
+ settle_ask(verdict)
66
+ waited_ms = ((@clock.call - asked_at) * 1000).round
67
+ end
59
68
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
60
69
  result = verdict.deny? ? denied(call, verdict) : dispatch(call)
61
70
  diff = file_change(call, before)
71
+ refresh_memory_index(call) if diff
62
72
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
63
73
  result, images = attach_images(call, result) if result[:images]
64
74
 
@@ -75,6 +85,7 @@ module Samagotchi
75
85
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
76
86
  completed[:images] = images if images&.any?
77
87
  completed[:diff] = diff if diff
88
+ completed[:waited_ms] = waited_ms if waited_ms
78
89
  emit(on_stream_event, completed)
79
90
 
80
91
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
@@ -108,6 +119,12 @@ module Samagotchi
108
119
  nil
109
120
  end
110
121
 
122
+ # A write/edit that changed a memory's file refreshes its index line,
123
+ # as memory_write does (MemoryBundle::IndexSync; a no-op elsewhere).
124
+ def refresh_memory_index(call)
125
+ MemoryBundle::IndexSync.refresh(Tools::ToolPath.normalize(call[:path]))
126
+ end
127
+
111
128
  # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
112
129
  # binary file). They become "?" here, before the output reaches the
113
130
  # conversation, the events and the saved session: JSON.generate raises
@@ -204,9 +204,8 @@ module Samagotchi
204
204
  # The verbatim "write to index.md" behavior (path: "index") must not
205
205
  # trigger upsert logic.
206
206
  unless entry_name == MEMORY_INDEX
207
- index_path = self.index_path_for(resolved_scope)
208
207
  if manage_index(resolved_scope, entry_name, bytes, description)
209
- message += " Index updated: #{index_path}"
208
+ message += " Index line refreshed automatically."
210
209
  end
211
210
  end
212
211
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -166,6 +166,7 @@ files:
166
166
  - lib/samagotchi/bundles/dev/manifest.yml
167
167
  - lib/samagotchi/bundles/guardrails/guardrails.md
168
168
  - lib/samagotchi/bundles/guardrails/guardrails/rules.yml
169
+ - lib/samagotchi/bundles/guardrails/guardrails/small-models.yml
169
170
  - lib/samagotchi/bundles/guardrails/manifest.yml
170
171
  - lib/samagotchi/bundles/known-names/hooks/known_names.rb
171
172
  - lib/samagotchi/bundles/known-names/known_names.md
@@ -217,6 +218,7 @@ files:
217
218
  - lib/samagotchi/guardrails/context.rb
218
219
  - lib/samagotchi/guardrails/gate.rb
219
220
  - lib/samagotchi/guardrails/load_failures.rb
221
+ - lib/samagotchi/guardrails/model_size.rb
220
222
  - lib/samagotchi/guardrails/protected_paths.rb
221
223
  - lib/samagotchi/guardrails/rules.rb
222
224
  - lib/samagotchi/guardrails/scratch_writes.rb
@@ -254,6 +256,7 @@ files:
254
256
  - lib/samagotchi/log_subscriber.rb
255
257
  - lib/samagotchi/memory_bundle.rb
256
258
  - lib/samagotchi/memory_bundle/builder.rb
259
+ - lib/samagotchi/memory_bundle/index_sync.rb
257
260
  - lib/samagotchi/memory_bundle/index_updater.rb
258
261
  - lib/samagotchi/memory_bundle/installer.rb
259
262
  - lib/samagotchi/memory_bundle/listing.rb