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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/docs/configuration.md +1 -0
- data/docs/guardrails.md +48 -2
- data/docs/memory.md +5 -3
- data/docs/plugins.md +13 -5
- data/docs/releasing.md +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +61 -11
- data/lib/samagotchi/bundles/system/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +13 -0
- data/lib/samagotchi/guardrail_wiring.rb +4 -1
- data/lib/samagotchi/guardrails/gate.rb +7 -4
- data/lib/samagotchi/guardrails/model_size.rb +87 -0
- data/lib/samagotchi/guardrails/rules.rb +35 -3
- data/lib/samagotchi/guardrails/targets.rb +29 -5
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/installer.rb +24 -3
- data/lib/samagotchi/session_commands.rb +6 -1
- data/lib/samagotchi/session_manager.rb +2 -2
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +20 -3
- data/lib/samagotchi/tools/memory.rb +1 -2
- data/lib/samagotchi/version.rb +1 -1
- 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?
|
|
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:
|
|
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)}"] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
208
|
+
message += " Index line refreshed automatically."
|
|
210
209
|
end
|
|
211
210
|
end
|
|
212
211
|
|
data/lib/samagotchi/version.rb
CHANGED
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.
|
|
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
|