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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +25 -2
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +90 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -7
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +91 -16
- data/lib/samagotchi/events.rb +8 -1
- data/lib/samagotchi/guardrails/model_size.rb +3 -4
- data/lib/samagotchi/guardrails/rules.rb +27 -17
- data/lib/samagotchi/guardrails/targets.rb +40 -1
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/llm/errors.rb +33 -1
- data/lib/samagotchi/llm_context_command.rb +151 -0
- data/lib/samagotchi/llm_context_override.rb +180 -0
- data/lib/samagotchi/llm_context_strategy.rb +63 -13
- data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
- data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
- data/lib/samagotchi/model_match.rb +54 -0
- data/lib/samagotchi/model_notes.rb +202 -0
- data/lib/samagotchi/parent_report.rb +12 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +15 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +29 -4
- data/lib/samagotchi/session_commands.rb +21 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +26 -3
- data/lib/samagotchi/system_prompt.rb +66 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
- data/lib/samagotchi/terminal_ui.rb +16 -6
- data/lib/samagotchi/tool_runner.rb +26 -1
- data/lib/samagotchi/tools/memory.rb +70 -3
- data/lib/samagotchi/turn_flow.rb +36 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -1
- data/lib/samagotchi/web/public/app.js +40 -6
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +5 -2
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/live_turn.js +1 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- data/lib/samagotchi/worker.rb +24 -18
- 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 (
|
|
11
|
-
#
|
|
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
|
|
126
|
-
# @param session_apply [Symbol, nil] the session's own apply rule
|
|
127
|
-
# @param session_budget [Integer, nil] the session's own budget (
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
139
|
-
|
|
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,
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
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
|