samagotchi 0.40.0 → 0.41.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +25 -2
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +90 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bridge/card_store.rb +6 -7
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  17. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/engine.rb +91 -16
  20. data/lib/samagotchi/events.rb +8 -1
  21. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  22. data/lib/samagotchi/guardrails/rules.rb +27 -17
  23. data/lib/samagotchi/guardrails/targets.rb +40 -1
  24. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  25. data/lib/samagotchi/llm/errors.rb +33 -1
  26. data/lib/samagotchi/llm_context_command.rb +151 -0
  27. data/lib/samagotchi/llm_context_override.rb +180 -0
  28. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  29. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  31. data/lib/samagotchi/model_match.rb +54 -0
  32. data/lib/samagotchi/model_notes.rb +202 -0
  33. data/lib/samagotchi/parent_report.rb +12 -2
  34. data/lib/samagotchi/plugin/context.rb +3 -2
  35. data/lib/samagotchi/plugin/sessions.rb +3 -1
  36. data/lib/samagotchi/reply_wait.rb +3 -17
  37. data/lib/samagotchi/self_report.rb +15 -0
  38. data/lib/samagotchi/send_command.rb +22 -2
  39. data/lib/samagotchi/session.rb +29 -4
  40. data/lib/samagotchi/session_commands.rb +21 -1
  41. data/lib/samagotchi/session_manager.rb +5 -2
  42. data/lib/samagotchi/session_metrics.rb +26 -3
  43. data/lib/samagotchi/system_prompt.rb +66 -11
  44. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
  46. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  47. data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
  48. data/lib/samagotchi/terminal_ui.rb +16 -6
  49. data/lib/samagotchi/tool_runner.rb +26 -1
  50. data/lib/samagotchi/tools/memory.rb +70 -3
  51. data/lib/samagotchi/turn_flow.rb +36 -8
  52. data/lib/samagotchi/turn_note.rb +40 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +20 -1
  55. data/lib/samagotchi/web/public/app.js +40 -6
  56. data/lib/samagotchi/web/public/ctx.js +28 -5
  57. data/lib/samagotchi/web/public/format.js +5 -2
  58. data/lib/samagotchi/web/public/index.html +12 -0
  59. data/lib/samagotchi/web/public/live_turn.js +1 -0
  60. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  61. data/lib/samagotchi/web/public/session_state.js +3 -0
  62. data/lib/samagotchi/web/public/timing.js +5 -1
  63. data/lib/samagotchi/web/session_summary.rb +2 -0
  64. data/lib/samagotchi/worker.rb +24 -18
  65. metadata +7 -1
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_context_override"
4
+ require_relative "llm_context_strategy"
5
+ require_relative "llm_context_edit"
6
+ require_relative "llm_context_stale"
7
+
8
+ module Samagotchi
9
+ # /llm-context, the session's own LLM context values
10
+ # (LLMContextOverride): with no arguments, the strategy, apply rule and
11
+ # budget the next turn runs under and where each came from (the session,
12
+ # models: <key>, a hosts entry, llm_context.*); with
13
+ # `strategy <layers|none>`, `apply <rule>` and `budget <N|off>` (any of
14
+ # them, in one line; `default` unsets one) it sets the session's own, and
15
+ # `reset` unsets them all. A change takes effect at the next turn's
16
+ # start (the REPL and the worker run it between turns), and the reply
17
+ # says what it costs: forget on or off changes the tool list and the
18
+ # system prompt (a full cache break); stale turned on stages the reads
19
+ # already superseded as one batch under the apply rule; a layer turned
20
+ # off sends its stubs whole again (the originals stay in the session, so
21
+ # turning it back on brings them back).
22
+ class LLMContextCommand
23
+ NAME = LLMContextOverride::COMMAND
24
+ USAGE = "usage: #{NAME} [strategy <none|stale,forget|default>] [apply <payoff|next_request|turn_end|default>] " \
25
+ "[budget <tokens|64k|off|default>] | #{NAME} reset".freeze
26
+ RESET = "reset"
27
+ FIELDS = LLMContextOverride::COMMAND_WORDS.invert.freeze
28
+
29
+ # @param engine [Engine] #llm_context_explained, #llm_context_override(=), #session
30
+ # @param save [#call] saves the session after a change
31
+ def initialize(engine:, save:)
32
+ @engine = engine
33
+ @save = save
34
+ end
35
+
36
+ # @param args [String] what follows /llm-context
37
+ # @return [Array(String, Boolean)] the reply and whether the values changed
38
+ # @raise [ArgumentError] a line that isn't one (its message and USAGE)
39
+ def run(args)
40
+ args = args.to_s.strip
41
+ return [listing(@engine.llm_context_explained), false] if args.empty?
42
+
43
+ session = @engine.session or raise ArgumentError, "no session yet"
44
+ before = @engine.llm_context_explained
45
+ updated = args.casecmp?(RESET) ? LLMContextOverride.new : LLMContextOverride.update(@engine.llm_context_override, words(args))
46
+ @engine.llm_context_override = updated
47
+ @save.call(session)
48
+ after = @engine.llm_context_explained
49
+ [[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true]
50
+ end
51
+
52
+ # The values, where each came from, and what the session sets itself.
53
+ def listing(explained)
54
+ return "llm context: can't be resolved for this model" unless explained
55
+
56
+ resolved = explained.resolved
57
+ own = @engine.llm_context_override
58
+ set = own ? LLMContextOverride::FIELDS.filter_map { |field| own_word(own, field) }.join("; ") : nil
59
+ [
60
+ "llm context: #{strategy_word(resolved)} (#{explained.strategy.where})",
61
+ " apply: #{resolved.apply} (#{explained.apply.where})",
62
+ " budget: #{resolved.budget_tokens ? "#{resolved.budget_tokens} tokens" : "off"} (#{explained.budget_tokens.where})",
63
+ " this session's own: #{set.to_s.empty? ? "none (follows the model, its host, then llm_context.*)" : set}"
64
+ ].join("\n")
65
+ end
66
+
67
+ private
68
+
69
+ # "strategy stale stale forget apply turn_end budget 64k" as fields
70
+ # and their words; a strategy takes the words up to the next field.
71
+ def words(args)
72
+ fields = {}
73
+ field = nil
74
+ args.split.each do |token|
75
+ if FIELDS.key?(token.downcase) && (field.nil? || fields[field])
76
+ field = FIELDS[token.downcase]
77
+ raise ArgumentError, "#{token} is given twice\n#{USAGE}" if fields.key?(field)
78
+
79
+ fields[field] = nil
80
+ elsif field.nil?
81
+ raise ArgumentError, "unknown #{NAME} word #{token}\n#{USAGE}"
82
+ elsif fields[field].nil? || field == :strategy
83
+ fields[field] = [fields[field], token].compact.join(" ")
84
+ else
85
+ raise ArgumentError, "#{FIELDS.key(field)} takes one value, not #{fields[field]} #{token}\n#{USAGE}"
86
+ end
87
+ end
88
+ missing = fields.find { |_field, word| word.nil? }
89
+ raise ArgumentError, "#{FIELDS.key(missing.first)} needs a value\n#{USAGE}" if missing
90
+
91
+ fields
92
+ end
93
+
94
+ def own_word(own, field)
95
+ word = LLMContextOverride.word(field, own.public_send(field))
96
+ word && "#{LLMContextOverride::COMMAND_WORDS[field]} #{word}"
97
+ end
98
+
99
+ def strategy_word(resolved) = resolved.active_layers.empty? ? "none" : resolved.active_layers.join(", ")
100
+
101
+ # What the change does from the next turn on.
102
+ def notes(session, before, after)
103
+ return [] unless before && after
104
+ return ["(no change to what the next turn runs under)"] if before.with(source: nil) == after.with(source: nil)
105
+
106
+ was = before.active_layers
107
+ now = after.active_layers
108
+ notes = ["From the next turn's start (a running turn keeps its own)."]
109
+ notes << forget_note(now.include?(:forget)) if was.include?(:forget) != now.include?(:forget)
110
+ notes << stale_note(session, after) if now.include?(:stale) && !was.include?(:stale)
111
+ (was - now).each do |layer|
112
+ count = applied_count(session.messages, layer)
113
+ next if count.zero?
114
+
115
+ notes << "#{layer}'s #{count} stub#{"s" if count != 1} go#{"es" if count == 1} out whole again (a cache break " \
116
+ "from the first; the originals stay in the session, and turning #{layer} back on brings the stubs back)."
117
+ end
118
+ notes
119
+ end
120
+
121
+ def forget_note(on)
122
+ verb = on ? "joins" : "leaves"
123
+ note = "forget_outputs #{verb} the tool list and the system prompt#{" (and outputs show their ids)" if on}: " \
124
+ "the next request re-reads the whole prompt (a full cache break)."
125
+ on ? note : "#{note} Its past forget_outputs calls and their results stay in the history."
126
+ end
127
+
128
+ # How many past reads are already superseded, and when they go in.
129
+ def stale_note(session, resolved)
130
+ found = LLMContextStale.found(session.messages, root: session.working_directory.to_s,
131
+ changes: resolved.stale_edits).size
132
+ return "stale: no past read is superseded yet." if found.zero?
133
+
134
+ when_in = case resolved.apply
135
+ when :next_request then "at the next request"
136
+ when :turn_end then "at the end of the next turn"
137
+ else "at the next request if what it frees pays for the re-read tail, else at the end of the next turn"
138
+ end
139
+ "stale: #{found} past read#{"s" if found != 1} already superseded go#{"es" if found == 1} in as one batch " \
140
+ "under apply #{resolved.apply}: #{when_in}."
141
+ end
142
+
143
+ def applied_count(messages, layer)
144
+ Array(messages).sum do |entry|
145
+ next 0 unless entry[:role].to_s == "tool_response"
146
+
147
+ LLMContextEdit.on(entry).values.count { |edit| edit.applied? && edit.kind == layer }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_context_strategy"
4
+
5
+ module Samagotchi
6
+ # A session's own llm_context values (Session#llm_context), which come
7
+ # before the model's, the host's and the global ones
8
+ # (LLMContextStrategy.resolve): the layers ([] is none, set on purpose),
9
+ # the apply rule and the budget in tokens (0 is off, set on purpose).
10
+ # nil in a field is unset: that value follows the model, the host, then
11
+ # llm_context.*, as with no override at all.
12
+ #
13
+ # Set by chi --llm-context, --llm-context-apply and --llm-context-budget
14
+ # at start (or --resume), /llm-context in the session and the web's info
15
+ # bar (which sends /llm-context). Saved in the session file, so a
16
+ # --resume and a respawned worker keep it; a plugin's fork copies it,
17
+ # a delegate child starts without one.
18
+ LLMContextOverride = Data.define(:strategy, :apply, :budget_tokens) do
19
+ def initialize(strategy: nil, apply: nil, budget_tokens: nil) = super
20
+
21
+ def empty? = strategy.nil? && apply.nil? && budget_tokens.nil?
22
+
23
+ # What the session file keeps: the set fields, nil when none is.
24
+ # @return [Hash, nil]
25
+ def to_file
26
+ return nil if empty?
27
+
28
+ { "strategy" => strategy&.map(&:to_s), "apply" => apply&.to_s, "budget_tokens" => budget_tokens }.compact
29
+ end
30
+
31
+ # LLMContextStrategy.resolve's session arguments.
32
+ def resolve_args = { session: strategy, session_apply: apply, session_budget: budget_tokens }
33
+
34
+ # How a field reads: "stale, forget", "none", "turn_end", "64000",
35
+ # "off"; nil when unset.
36
+ def self.word(field, value)
37
+ return nil if value.nil?
38
+
39
+ case field
40
+ when :strategy then value.empty? ? "none" : value.join(", ")
41
+ when :budget_tokens then value.positive? ? value.to_s : "off"
42
+ else value.to_s
43
+ end
44
+ end
45
+ end
46
+
47
+ class LLMContextOverride
48
+ FIELDS = %i[strategy apply budget_tokens].freeze
49
+ # A field's word for "unset: follow the model" (/llm-context strategy default).
50
+ DEFAULT_WORD = "default"
51
+ OFF_WORDS = %w[off 0].freeze
52
+ # The start flags (chi, chi send --new), by field.
53
+ FLAGS = { strategy: "--llm-context", apply: "--llm-context-apply", budget_tokens: "--llm-context-budget" }.freeze
54
+ # The session command, and its words by field.
55
+ COMMAND = "/llm-context"
56
+ COMMAND_WORDS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
57
+
58
+ # The /llm-context line that sets +words+ (field => value as typed,
59
+ # checked by .update first): what a flag on an existing session runs.
60
+ # @return [String]
61
+ def self.command_line(words)
62
+ parts = words.map do |field, word|
63
+ word = word.to_s.strip
64
+ word = parse_strategy(word).then { |layers| layers.empty? ? "none" : layers.join(",") } if field == :strategy && !word.casecmp?(DEFAULT_WORD)
65
+ "#{COMMAND_WORDS.fetch(field)} #{word}"
66
+ end
67
+ [COMMAND, *parts].join(" ")
68
+ end
69
+
70
+ # A session file's "llm_context" (or an override already): nil when
71
+ # absent or empty. A field that doesn't read (an unknown layer, an
72
+ # out-of-range budget: a newer chi's, or a hand edit) is left unset,
73
+ # without a warning: that value follows the model (.unread keeps it).
74
+ # @return [LLMContextOverride, nil]
75
+ def self.from_file(data)
76
+ return data.empty? ? nil : data if data.is_a?(self)
77
+ return nil unless data.is_a?(Hash)
78
+
79
+ data = data.transform_keys(&:to_s)
80
+ override = new(strategy: file_value { file_strategy(data["strategy"]) },
81
+ apply: file_value { parse_apply(data["apply"].to_s) unless data["apply"].nil? },
82
+ budget_tokens: file_value { file_budget(data["budget_tokens"]) })
83
+ override.empty? ? nil : override
84
+ end
85
+
86
+ # The fields of a session file's "llm_context" that .from_file couldn't
87
+ # read, as written (String keys): saved back as they were.
88
+ # @return [Hash]
89
+ def self.unread(data)
90
+ return {} unless data.is_a?(Hash)
91
+
92
+ data = data.transform_keys(&:to_s)
93
+ read = from_file(data)
94
+ FIELDS.each_with_object({}) do |field, unread|
95
+ key = field.to_s
96
+ unread[key] = data[key] if !data[key].nil? && read&.public_send(field).nil?
97
+ end
98
+ end
99
+
100
+ def self.file_value
101
+ yield
102
+ rescue ArgumentError
103
+ nil
104
+ end
105
+
106
+ def self.file_strategy(raw)
107
+ return nil unless raw.is_a?(Array) || raw.is_a?(String)
108
+ return [] if raw == []
109
+
110
+ parse_strategy(Array(raw).join(","))
111
+ end
112
+
113
+ def self.file_budget(raw)
114
+ raw.is_a?(Integer) ? check_budget(raw) : nil
115
+ end
116
+
117
+ # +current+ with +words+ (field => what the user typed) applied: each
118
+ # word is a value, or "default" (unset). The flags and /llm-context
119
+ # both go through here.
120
+ # @param current [LLMContextOverride, nil]
121
+ # @param words [Hash{Symbol => String}]
122
+ # @return [LLMContextOverride]
123
+ # @raise [ArgumentError] a word that isn't a value, saying which
124
+ def self.update(current, words)
125
+ fields = words.to_h do |field, word|
126
+ raise ArgumentError, "unknown llm_context field #{field}" unless FIELDS.include?(field)
127
+
128
+ text = word.to_s.strip
129
+ [field, text.casecmp?(DEFAULT_WORD) ? nil : send(:"parse_#{field}", text)]
130
+ end
131
+ (current || new).with(**fields)
132
+ end
133
+
134
+ # "stale,forget", "stale forget", "stale|forget" or "none".
135
+ # @return [Array<Symbol>] [] for none
136
+ def self.parse_strategy(text)
137
+ names = text.downcase.split(/[\s,|]+/).reject(&:empty?)
138
+ raise ArgumentError, "a strategy is none or a list of #{LLMContextStrategy::LAYERS.join(", ")}" if names.empty?
139
+ return [] if names == [LLMContextStrategy::NONE.to_s]
140
+
141
+ unknown = names - LLMContextStrategy::LAYERS.map(&:to_s)
142
+ unless unknown.empty?
143
+ raise ArgumentError, "unknown llm_context strategy #{unknown.join(", ")} (none, or a list of " \
144
+ "#{LLMContextStrategy::LAYERS.join(", ")})"
145
+ end
146
+ LLMContextStrategy::LAYERS.select { |layer| names.include?(layer.to_s) }
147
+ end
148
+
149
+ # payoff, next_request or turn_end.
150
+ # @return [Symbol]
151
+ def self.parse_apply(text)
152
+ rule = LLMContextStrategy::APPLIES.find { |name| name.to_s.casecmp?(text) }
153
+ rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
154
+ end
155
+
156
+ # The smallest and largest budget a session may set.
157
+ MIN_BUDGET = 4_000
158
+ MAX_BUDGET = 10_000_000
159
+
160
+ # "64000", "64k" or "off" (0).
161
+ # @return [Integer] 0 for off
162
+ def self.parse_budget_tokens(text)
163
+ return 0 if OFF_WORDS.include?(text.downcase)
164
+
165
+ match = /\A(\d[\d_]*)(k)?\z/i.match(text)
166
+ raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
167
+
168
+ check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
169
+ end
170
+
171
+ # +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
172
+ def self.check_budget(tokens)
173
+ return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
174
+
175
+ raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
176
+ end
177
+
178
+ private_class_method :file_value, :file_strategy, :file_budget
179
+ end
180
+ end
@@ -7,8 +7,8 @@ module Samagotchi
7
7
  # llm_context_strategy (models.<key>, hosts.<name>), each a layer name,
8
8
  # "|"-separated names or a YAML list.
9
9
  #
10
- # Resolution, first set wins: the session's own (a later per-session
11
- # override; nothing sets it yet), models.<key> (by the model's lookup
10
+ # Resolution, first set wins: the session's own (Session#llm_context,
11
+ # set by chi --llm-context, /llm-context and the web), models.<key> (by the model's lookup
12
12
  # names, as window_tokens), hosts.<name>, then llm_context.strategy.
13
13
  # An unknown layer warns and is none (every layer is built: stale, and
14
14
  # forget, experimental, with its tool forget_outputs).
@@ -118,29 +118,71 @@ module Samagotchi
118
118
  nil
119
119
  end
120
120
 
121
+ # Where one value came from: :session, :model_setting, :host_setting
122
+ # or :config, and the place as a warning names it ("the session",
123
+ # "models: deepseek", "hosts entry 'box'", "llm_context.apply").
124
+ Origin = Data.define(:source, :where)
125
+ SESSION_ORIGIN = Origin.new(source: :session, where: "the session")
126
+
127
+ # What a turn's strategy, apply rule and budget are, each with its
128
+ # Origin (/llm-context, /stats, the web's info bar).
129
+ Explained = Data.define(:resolved, :strategy, :apply, :budget_tokens) do
130
+ # The plain form /stats, the snapshot and the web read: each value
131
+ # with its source and place, and +own+, the session's own values
132
+ # (LLMContextOverride#to_file; nil without any).
133
+ # @return [Hash] symbol keys
134
+ def summary(own = nil)
135
+ layers = resolved.active_layers
136
+ { strategy: layers.empty? ? NONE.to_s : layers.join(","), strategy_source: strategy.source.to_s,
137
+ strategy_where: strategy.where, apply: resolved.apply.to_s, apply_source: apply.source.to_s,
138
+ apply_where: apply.where, budget_tokens: resolved.budget_tokens, budget_source: budget_tokens.source.to_s,
139
+ budget_where: budget_tokens.where, own: own&.to_file }
140
+ end
141
+ end
142
+
121
143
  # The strategy for +target+'s turn.
122
144
  # @param target [HostRegistry::ModelTarget, nil]
123
145
  # @param names [Array<String>] HostRegistry#lookup_names
124
146
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
125
- # @param session [Array<Symbol>, nil] the session's own layers (none yet)
126
- # @param session_apply [Symbol, nil] the session's own apply rule (none yet)
127
- # @param session_budget [Integer, nil] the session's own budget (none yet)
147
+ # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
148
+ # @param session_apply [Symbol, nil] the session's own apply rule
149
+ # @param session_budget [Integer, nil] the session's own budget (0: off)
128
150
  # @return [Resolved]
129
151
  def resolve(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
152
+ explain(target, names: names, models: models, session: session, session_apply: session_apply,
153
+ session_budget: session_budget).resolved
154
+ end
155
+
156
+ # #resolve, with where each value came from.
157
+ # @return [Explained]
158
+ def explain(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
130
159
  models ||= ConfigFile.model_settings
131
160
  layers, source, where = layers_for(target, names, models, session)
132
- resolved(layers, source, where).with(apply: apply_for(target, names, models, session_apply),
133
- protect_steps: protect_steps, stale_edits: Config.get(STALE_EDITS_SETTING) == true,
134
- budget_tokens: budget_for(target, names, models, session_budget))
161
+ apply, apply_origin = apply_for(target, names, models, session_apply)
162
+ budget, budget_origin = budget_for(target, names, models, session_budget)
163
+ resolved = resolved(layers, source, where).with(apply: apply, protect_steps: protect_steps,
164
+ stale_edits: Config.get(STALE_EDITS_SETTING) == true,
165
+ budget_tokens: budget)
166
+ Explained.new(resolved: resolved, strategy: Origin.new(source: source, where: where), apply: apply_origin,
167
+ budget_tokens: budget_origin)
135
168
  end
136
169
 
170
+ # [the budget, its Origin]: the session's own first (0 there is off,
171
+ # whatever the model says).
137
172
  def budget_for(target, names, models, session_budget)
138
- session_budget || ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)&.last ||
139
- target&.entry&.llm_context_budget_tokens || parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING)
173
+ return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
174
+
175
+ key, budget = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)
176
+ return [budget, Origin.new(source: :model_setting, where: "models: #{key}")] if budget
177
+
178
+ budget = target&.entry&.llm_context_budget_tokens
179
+ return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
180
+
181
+ [parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING), Origin.new(source: :config, where: BUDGET_SETTING)]
140
182
  end
141
183
 
142
184
  def layers_for(target, names, models, session)
143
- return [session, :session, "the session"] if session
185
+ return [session, :session, SESSION_ORIGIN.where] if session
144
186
 
145
187
  key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
146
188
  return [layers, :model_setting, "models: #{key}"] if layers
@@ -151,9 +193,17 @@ module Samagotchi
151
193
  [parse(Config.get(SETTING), SETTING) || [], :config, SETTING]
152
194
  end
153
195
 
196
+ # [the apply rule, its Origin].
154
197
  def apply_for(target, names, models, session_apply)
155
- session_apply || ConfigFile.model_setting(names, :llm_context_apply, models: models)&.last ||
156
- target&.entry&.llm_context_apply || parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY
198
+ return [session_apply, SESSION_ORIGIN] if session_apply
199
+
200
+ key, apply = ConfigFile.model_setting(names, :llm_context_apply, models: models)
201
+ return [apply, Origin.new(source: :model_setting, where: "models: #{key}")] if apply
202
+
203
+ apply = target&.entry&.llm_context_apply
204
+ return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
205
+
206
+ [parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY, Origin.new(source: :config, where: APPLY_SETTING)]
157
207
  end
158
208
 
159
209
  # llm_context.protect_steps: 0 or more (a negative one is the default).
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../tools/memory"
5
+ require_relative "../model_notes"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # The size of one scope's memory index as a prompt carries it: every
10
+ # session's system prompt holds both indexes in full, so their size is
11
+ # paid on every request. tokens is the estimate ContextStatus uses
12
+ # (characters / context.chars_per_token, rounded up), lines the
13
+ # non-blank lines, bytes the UTF-8 size.
14
+ #
15
+ # memory_write and write/edit on a memory compare the size before and
16
+ # after a write against memory.index_warn_tokens (#crossing_note);
17
+ # chi self shows it (#summary), and a session's figure comes from the
18
+ # index text its prompt holds (SystemPrompt).
19
+ IndexSize = Data.define(:scope, :bytes, :lines, :tokens) do
20
+ # @param text [String, nil] the index text
21
+ # @param chars_per_token [Float]
22
+ # @return [IndexSize]
23
+ def self.of_text(scope, text, chars_per_token: self.chars_per_token)
24
+ text = text.to_s
25
+ new(scope: scope.to_s, bytes: text.bytesize, lines: text.each_line.count { |line| !line.strip.empty? },
26
+ tokens: (text.length / chars_per_token).ceil)
27
+ end
28
+
29
+ # +scope+'s index as the prompt carries it before a session's own
30
+ # filters (mutes): index.md, or the file listing without one, minus
31
+ # the model notes' lines (ModelNotes.filter_index).
32
+ # @param env [Hash] the XDG env the memories dir resolves from
33
+ # @return [IndexSize]
34
+ def self.of_scope(scope, chars_per_token: self.chars_per_token, env: ENV)
35
+ text = Tools::MemoryRead.scoped_index(scope, env: env)
36
+ of_text(scope, ModelNotes.filter_index(text, scope, env: env), chars_per_token: chars_per_token)
37
+ end
38
+
39
+ # of_scope, or nil when the index can't be read (a write's measure
40
+ # must never fail the write).
41
+ def self.measure(scope)
42
+ of_scope(scope)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # context.chars_per_token, 4.0 when it isn't positive.
48
+ def self.chars_per_token
49
+ value = Config.get("context.chars_per_token").to_f
50
+ value.positive? ? value : 4.0
51
+ rescue StandardError
52
+ 4.0
53
+ end
54
+
55
+ # memory.index_warn_tokens: the tokens one scope's index may hold
56
+ # before a write that crosses it gets a note; 0 turns the note off.
57
+ def self.warn_limit
58
+ Config.get("memory.index_warn_tokens").to_i
59
+ rescue StandardError
60
+ 0
61
+ end
62
+
63
+ # The note a write gets when it took the index from at or under
64
+ # +limit+ to over it, nil otherwise: once per crossing, so a write
65
+ # while the index is already over gets none and the note doesn't
66
+ # start a loop of small tightenings. Sessions sharing the dir measure
67
+ # on their own (before and after aren't one locked step), so writes
68
+ # in parallel sessions racing over the limit may each get it.
69
+ # @param before [IndexSize, nil]
70
+ # @param after [IndexSize, nil]
71
+ # @return [String, nil]
72
+ def self.crossing_note(before, after, limit = warn_limit)
73
+ return nil unless before && after && after.over?(limit) && !before.over?(limit)
74
+
75
+ "Note: the #{after.scope} memory index is now ~#{after.tokens} tokens (over memory.index_warn_tokens " \
76
+ "#{limit}) and is sent with every prompt. When you next have a moment, tighten long index " \
77
+ "descriptions (memory_write with name, scope and description only). Don't remove or merge memories " \
78
+ "unless the user asks."
79
+ end
80
+
81
+ # "450", "2.0k": a token count as the size readouts show it (the
82
+ # web's ctx.js memoryIndexText too; spec/shared/labels_matrix.json).
83
+ def self.count_text(tokens)
84
+ value = tokens.to_i
85
+ value >= 1000 ? "#{format("%.1f", (value / 100.0).round / 10.0)}k" : value.to_s
86
+ end
87
+
88
+ # Whether the index holds more than +limit+ tokens; a limit that
89
+ # isn't positive is off.
90
+ def over?(limit) = limit.to_i.positive? && tokens > limit.to_i
91
+
92
+ # "system ~2.0k tokens (54 lines)", with ", over 2500" in the
93
+ # parentheses when over +limit+: chi self's row.
94
+ def summary(limit = 0)
95
+ over = over?(limit) ? ", over #{limit}" : ""
96
+ "#{scope} ~#{self.class.count_text(tokens)} tokens (#{lines} #{lines == 1 ? "line" : "lines"}#{over})"
97
+ end
98
+
99
+ # { tokens:, lines: }: the session's block (SessionMetrics).
100
+ def figures = { tokens: tokens, lines: lines }
101
+ end
102
+ end
103
+ end
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  end
39
39
 
40
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,
41
+ # overlay's): a *.md right in that scope's memories dir, not index.md (in any case),
42
42
  # not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
43
43
  # uses it too: write/edit there is what memory_write does. A symlink
44
44
  # isn't: write/edit follow it to wherever it points, memory_write
@@ -47,7 +47,8 @@ module Samagotchi
47
47
  path = File.expand_path(path.to_s)
48
48
  name = File.basename(path, ".md")
49
49
  return nil unless File.extname(path) == ".md"
50
- return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
50
+ # Any case: on a case-insensitive disk INDEX.md is the index itself.
51
+ return nil if name.casecmp?(IndexUpdater::MEMORY_INDEX) || name.start_with?(".") || File.symlink?(path)
51
52
 
52
53
  dir = real(File.dirname(path))
53
54
  SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Which models a `models:` list is for, in one grammar shared by the
5
+ # guardrails rules' `models:`, `guardrails.small_models` and the model
6
+ # notes' first line: entries `|`-separated (or a list), each `small` (the
7
+ # caller says whether the model is a small one) or an fnmatch glob
8
+ # (case-insensitive, extglob) on the bare model name or the model key
9
+ # (ModelOverlay.key_for). Any entry matching wins; no model matches
10
+ # nothing.
11
+ module ModelMatch
12
+ GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
13
+ SMALL = "small"
14
+ # A model note's first line (ModelNotes): `models: …`, any case, after
15
+ # an optional UTF-8 BOM.
16
+ MODELS_LINE = /\A\uFEFF?\s*models:(.*)\z/i
17
+
18
+ module_function
19
+
20
+ # The entries of a `models:` value: a string, or a list of them, each
21
+ # split on `|`, stripped, blanks dropped.
22
+ # @param value [String, Array<String>, nil]
23
+ # @return [Array<String>]
24
+ def parse(value)
25
+ Array(value).flat_map { |entry| entry.to_s.split("|") }.map(&:strip).reject(&:empty?)
26
+ end
27
+
28
+ # The entries of +text+'s first line when it is a `models:` line with
29
+ # at least one; nil otherwise.
30
+ # @return [Array<String>, nil]
31
+ def models_line(text)
32
+ first = text.to_s.each_line.first.to_s.chomp
33
+ match = first.match(MODELS_LINE)
34
+ entries = match && parse(match[1])
35
+ entries unless entries.nil? || entries.empty?
36
+ end
37
+
38
+ # @param entries [Array<String>] parsed entries (#parse)
39
+ # @param name [String, nil] the bare model name
40
+ # @param key [String, nil] its model key
41
+ # @param small [#call] → whether the model is a small one; called only
42
+ # for a `small` entry
43
+ def match?(entries, name:, key:, small:)
44
+ return false if name.nil? || name.to_s.empty?
45
+
46
+ entries.any? { |entry| entry == SMALL ? small.call : glob?(entry, name: name, key: key) }
47
+ end
48
+
49
+ # Whether the glob +entry+ matches the name or the key.
50
+ def glob?(entry, name:, key:)
51
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
52
+ end
53
+ end
54
+ end