samagotchi 0.40.0 → 0.42.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 +82 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +32 -7
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +108 -1
- data/docs/sessions.md +5 -5
- data/docs/testing.md +16 -0
- 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/child_reports.rb +8 -4
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_command.rb +5 -2
- data/lib/samagotchi/engine.rb +121 -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 +205 -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/prompt_note.rb +53 -0
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +47 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +44 -4
- data/lib/samagotchi/session_commands.rb +45 -6
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +47 -7
- data/lib/samagotchi/system_prompt.rb +79 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -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 +44 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -6
- data/lib/samagotchi/web/public/app.js +47 -7
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +29 -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 +6 -0
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_view.js +15 -2
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +36 -18
- data/lib/samagotchi/worker_wakes.rb +2 -1
- metadata +8 -1
|
@@ -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
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "model_match"
|
|
6
|
+
require_relative "model_overlay"
|
|
7
|
+
require_relative "memory_paths"
|
|
8
|
+
require_relative "muted_memories"
|
|
9
|
+
require_relative "tools/memory"
|
|
10
|
+
require_relative "guardrails/model_size"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# Model notes: memories named `model_notes_<name>` whose first line says
|
|
14
|
+
# which models they are for, in ModelMatch's grammar:
|
|
15
|
+
#
|
|
16
|
+
# models: deepseek-*|*deepseek-v4*
|
|
17
|
+
# Working habits for this model: ...
|
|
18
|
+
#
|
|
19
|
+
# Every note matching the session's model is loaded into the system
|
|
20
|
+
# prompt (SystemPrompt#model_notes_section, after identity), stacked: the
|
|
21
|
+
# system scope's, then the project's, by name within a scope. Notes add
|
|
22
|
+
# habits; none overrides another. The body is read through memory_read,
|
|
23
|
+
# so a note's own model overlays are appended as for any memory; the
|
|
24
|
+
# models: line is left out. Their index lines are left out of the
|
|
25
|
+
# prompt's indexes (#filter_index): the body is already there.
|
|
26
|
+
module ModelNotes
|
|
27
|
+
PREFIX = Tools::MODEL_NOTES_PREFIX
|
|
28
|
+
SCOPES = %w[system project].freeze
|
|
29
|
+
# A note over this, or all of them over TOTAL_LIMIT, still loads but
|
|
30
|
+
# warns once: they cost every request.
|
|
31
|
+
NOTE_LIMIT = 1_500
|
|
32
|
+
TOTAL_LIMIT = 3_000
|
|
33
|
+
|
|
34
|
+
# name: the memory name; scope: "system" or "project"; body: what the
|
|
35
|
+
# prompt carries (overlays appended, the models: line dropped); chars:
|
|
36
|
+
# its length; digest: a short SHA-256 of it.
|
|
37
|
+
Note = Data.define(:name, :scope, :body, :chars, :digest)
|
|
38
|
+
|
|
39
|
+
@warned = Set.new
|
|
40
|
+
@warn_mutex = Mutex.new
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# The notes for a model.
|
|
45
|
+
# @param name [String, nil] the bare model id (no host prefix)
|
|
46
|
+
# @param key [String, nil] its model key (ModelOverlay.key_for)
|
|
47
|
+
# @param fallback_key [String, nil] the overlay key read when +key+ has
|
|
48
|
+
# none (SystemPrompt::ModelIdentity#fallback_key)
|
|
49
|
+
# @param muted [Array<String>] the session's muted names (MutedMemories)
|
|
50
|
+
# @param small [#call, nil] → whether the model is a small one; nil:
|
|
51
|
+
# guardrails.small_models (Guardrails::ModelSize)
|
|
52
|
+
# @param env [Hash] the XDG env the memories dirs resolve from
|
|
53
|
+
# @param warn [Boolean] false: no size warnings (a read-only report such
|
|
54
|
+
# as chi self; the prompt's build gives them)
|
|
55
|
+
# @return [Array<Note>]
|
|
56
|
+
def for(name:, key:, fallback_key: nil, muted: [], small: nil, env: ENV, warn: true)
|
|
57
|
+
return [] if name.nil? || name.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
small ||= -> { Guardrails::ModelSize.small?(name, key) }
|
|
60
|
+
small_once = memo(small)
|
|
61
|
+
seen = Set.new
|
|
62
|
+
notes = SCOPES.flat_map do |scope|
|
|
63
|
+
files(scope, env).filter_map do |path|
|
|
64
|
+
next unless seen.add?(File.expand_path(path))
|
|
65
|
+
|
|
66
|
+
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
warn_sizes(notes) if warn
|
|
70
|
+
notes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# +text+ (+scope+'s memory index) without the lines naming a model note
|
|
74
|
+
# that loads for some model (its file in +scope+ has a `models:` line);
|
|
75
|
+
# the same object when it names none. A `model_notes_*` memory that
|
|
76
|
+
# isn't one keeps its line, so it doesn't vanish. +env+: the XDG env
|
|
77
|
+
# the scope's dir resolves from.
|
|
78
|
+
def filter_index(text, scope, env: ENV)
|
|
79
|
+
return text if text.nil? || !text.include?(PREFIX)
|
|
80
|
+
|
|
81
|
+
dir = MemoryPaths.scope_dir(scope, env: env)
|
|
82
|
+
names = files_in(dir).map { |path| File.basename(path) }
|
|
83
|
+
text.each_line.reject do |line|
|
|
84
|
+
name = MutedMemories.index_line_name(line).to_s
|
|
85
|
+
name.start_with?(PREFIX) && !name.include?(".") && names.include?("#{name}.md") &&
|
|
86
|
+
note_file?(File.join(dir, "#{name}.md"))
|
|
87
|
+
end.join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether +path+ is a model note's file: it has a models: first line.
|
|
91
|
+
def note_file?(path)
|
|
92
|
+
File.file?(path) && !ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets)).nil?
|
|
93
|
+
rescue StandardError
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Forgets which warnings were given (specs).
|
|
98
|
+
def reset_warnings!
|
|
99
|
+
@warn_mutex.synchronize { @warned.clear }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def files(scope, env = ENV) = files_in(MemoryPaths.scope_dir(scope, env: env))
|
|
103
|
+
|
|
104
|
+
# The `model_notes_*.md` files in +dir+, by their names on disk: the
|
|
105
|
+
# prefix exactly as written (a case-insensitive disk's glob matches
|
|
106
|
+
# MODEL_NOTES_x.md too, which the guardrails would not see as a note).
|
|
107
|
+
def files_in(dir)
|
|
108
|
+
Dir.glob(File.join(dir, "#{PREFIX}*.md")).select { |path| File.basename(path).start_with?(PREFIX) }.sort
|
|
109
|
+
rescue StandardError
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
|
|
114
|
+
stem = File.basename(path, ".md")
|
|
115
|
+
return nil if dotted?(path, stem)
|
|
116
|
+
return nil if MutedMemories.muted?(stem, muted)
|
|
117
|
+
|
|
118
|
+
entries = models_of(path)
|
|
119
|
+
return nil unless entries
|
|
120
|
+
return nil unless ModelMatch.match?(entries, name: name, key: key, small: small)
|
|
121
|
+
|
|
122
|
+
body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
|
|
123
|
+
return nil if body.empty?
|
|
124
|
+
|
|
125
|
+
body = with_overlay(body, stem, scope, [key, fallback_key])
|
|
126
|
+
|
|
127
|
+
Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The note's model overlay appended as memory_read appends one: the
|
|
133
|
+
# key's, else the fallback key's. The file is read by its own path, not
|
|
134
|
+
# by name through memory_read (whose names are comma lists).
|
|
135
|
+
def with_overlay(body, stem, scope, keys)
|
|
136
|
+
keys.compact.each do |overlay_key|
|
|
137
|
+
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
|
|
138
|
+
next unless overlay && File.file?(overlay)
|
|
139
|
+
|
|
140
|
+
return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
|
|
141
|
+
"#{File.read(overlay, encoding: "UTF-8").strip}"
|
|
142
|
+
end
|
|
143
|
+
body
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A model overlay of a note (`model_notes_a.<key>.md`) is read with
|
|
147
|
+
# its note; any other dotted name is skipped with a warning (memory_write
|
|
148
|
+
# refuses it: next to a base it would read as an overlay).
|
|
149
|
+
def dotted?(path, stem)
|
|
150
|
+
return false unless stem.include?(".")
|
|
151
|
+
|
|
152
|
+
unless ModelOverlay.overlay_file?(path)
|
|
153
|
+
warn_once("model_note_skipped", "Warning: model note #{path} skipped: a model note's name has no dot " \
|
|
154
|
+
"(rename it, e.g. #{Tools::MemoryWrite.undotted(stem)}.md)", path)
|
|
155
|
+
end
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The entries of the file's `models:` first line; nil (warned once)
|
|
160
|
+
# without one.
|
|
161
|
+
def models_of(path)
|
|
162
|
+
entries = ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets))
|
|
163
|
+
return entries if entries
|
|
164
|
+
|
|
165
|
+
warn_once("model_note_skipped", "Warning: model note #{path} skipped: its first line must be " \
|
|
166
|
+
"`models: <glob>|small|…`", path)
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def warn_sizes(notes)
|
|
171
|
+
notes.each do |note|
|
|
172
|
+
next unless note.chars > NOTE_LIMIT
|
|
173
|
+
|
|
174
|
+
warn_once("model_notes_large", "Warning: model note #{note.name} (#{note.scope}) is #{note.chars} chars, over " \
|
|
175
|
+
"#{NOTE_LIMIT}: it is sent with every request", note.name)
|
|
176
|
+
end
|
|
177
|
+
total = notes.sum(&:chars)
|
|
178
|
+
return unless total > TOTAL_LIMIT
|
|
179
|
+
|
|
180
|
+
warn_once("model_notes_large", "Warning: the model notes for this model are #{total} chars, over #{TOTAL_LIMIT}: " \
|
|
181
|
+
"they are sent with every request", notes.map(&:name).join(","))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Once per message for the process (a prompt is built again on a
|
|
185
|
+
# model switch, another thinking level, changed tools).
|
|
186
|
+
def warn_once(event, echo, memory)
|
|
187
|
+
first = @warn_mutex.synchronize { @warned.add?(echo) }
|
|
188
|
+
Log.warn(:memory, event, echo: echo, memory: memory) if first
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def memo(callable)
|
|
192
|
+
value = nil
|
|
193
|
+
known = false
|
|
194
|
+
lambda do
|
|
195
|
+
unless known
|
|
196
|
+
value = callable.call
|
|
197
|
+
known = true
|
|
198
|
+
end
|
|
199
|
+
value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private_class_method :note_file?, :files, :files_in, :note_at, :with_overlay, :dotted?, :models_of, :warn_sizes, :warn_once, :memo
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -94,7 +94,7 @@ module Samagotchi
|
|
|
94
94
|
# @return [Hash]
|
|
95
95
|
def stop_facts(result)
|
|
96
96
|
case status(result)
|
|
97
|
-
when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
|
|
97
|
+
when "failed" then { error_kind: result.error_kind, retryable: result.retryable, kept_steps: result.kept_steps }.compact
|
|
98
98
|
when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
|
|
99
99
|
else {}
|
|
100
100
|
end
|
|
@@ -242,7 +242,7 @@ module Samagotchi
|
|
|
242
242
|
|
|
243
243
|
def no_reply_line(result, session_id = nil)
|
|
244
244
|
case result.outcome
|
|
245
|
-
when "failed" then result
|
|
245
|
+
when "failed" then failed_line(result)
|
|
246
246
|
when "canceled" then "the turn was canceled"
|
|
247
247
|
when "completed" then "the turn ended with no visible answer"
|
|
248
248
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
@@ -253,6 +253,16 @@ module Samagotchi
|
|
|
253
253
|
end
|
|
254
254
|
end
|
|
255
255
|
|
|
256
|
+
# A failed turn's line; one whose work stayed says so, so the task isn't
|
|
257
|
+
# sent again over what it changed.
|
|
258
|
+
def failed_line(result)
|
|
259
|
+
why = result.text.to_s.strip
|
|
260
|
+
steps = result.kept_steps
|
|
261
|
+
return why.empty? ? "the turn failed" : "the turn failed: #{why}" unless steps.is_a?(Integer)
|
|
262
|
+
|
|
263
|
+
"the turn failed after #{steps} tool step#{"s" if steps != 1}#{": #{why}" unless why.empty?}; its work so far stays"
|
|
264
|
+
end
|
|
265
|
+
|
|
256
266
|
def first_line(text)
|
|
257
267
|
text.to_s.strip.lines.first.to_s.strip
|
|
258
268
|
end
|
|
@@ -20,12 +20,13 @@ module Samagotchi
|
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
22
|
# +model_name+ (the session's resolved model ref, live after /model) and
|
|
23
|
-
# +state_dir+ are what ctx.sessions forks with
|
|
23
|
+
# +state_dir+ are what ctx.sessions forks with, and +llm_context+ (the
|
|
24
|
+
# session's own LLMContextOverride, nil without one), which a fork copies; +model_key+ is the
|
|
24
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
25
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
26
27
|
# (reason, label), each true when it acted on a running turn.
|
|
27
28
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
28
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
|
|
29
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
|
|
29
30
|
keyword_init: true)
|
|
30
31
|
|
|
31
32
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
@@ -32,6 +32,7 @@ module Samagotchi
|
|
|
32
32
|
# Start a child session from +messages+ (usually ctx.messages plus
|
|
33
33
|
# more). With no prompt the child waits idle for the user; with one it
|
|
34
34
|
# runs it as its first turn, and counts against session.max_children.
|
|
35
|
+
# It keeps this session's own llm_context values (Session#llm_context).
|
|
35
36
|
# Each image a message names is copied into the child, or dropped with
|
|
36
37
|
# a note in its message when its file is gone.
|
|
37
38
|
# @param messages [Array<Hash>] the child's conversation to start with
|
|
@@ -52,7 +53,8 @@ module Samagotchi
|
|
|
52
53
|
child = SessionManager.spawn_session(
|
|
53
54
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
54
55
|
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
|
|
55
|
-
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir
|
|
56
|
+
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
57
|
+
llm_context: @host.llm_context&.call
|
|
56
58
|
)
|
|
57
59
|
child.id
|
|
58
60
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model note a session's system prompt carried (ModelNotes::Note
|
|
5
|
+
# without its body): what the session file's `prompt_notes` records at
|
|
6
|
+
# each prompt build, so /stats, /model, the web and a later comparison
|
|
7
|
+
# read what the prompt really held. name: the memory name; scope:
|
|
8
|
+
# "system" or "project"; chars: the body's length in the prompt; digest:
|
|
9
|
+
# a short SHA-256 of that body (ModelNotes::Note#digest).
|
|
10
|
+
PromptNote = Data.define(:name, :scope, :chars, :digest) do
|
|
11
|
+
# @param note [ModelNotes::Note]
|
|
12
|
+
def self.from_note(note)
|
|
13
|
+
new(name: note.name, scope: note.scope, chars: note.chars, digest: note.digest)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A saved or wire record (string or symbol keys); nil without a name.
|
|
17
|
+
def self.from_h(raw)
|
|
18
|
+
return raw if raw.is_a?(PromptNote)
|
|
19
|
+
return nil unless raw.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
get = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key] }
|
|
22
|
+
name = get.call(:name).to_s.strip
|
|
23
|
+
return nil if name.empty?
|
|
24
|
+
|
|
25
|
+
chars = get.call(:chars)
|
|
26
|
+
new(name: name, scope: get.call(:scope)&.to_s, chars: chars.is_a?(Numeric) ? chars.to_i : nil,
|
|
27
|
+
digest: get.call(:digest)&.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The records of a saved list (others dropped).
|
|
31
|
+
# @return [Array<PromptNote>]
|
|
32
|
+
def self.list(raw)
|
|
33
|
+
Array(raw).filter_map { |entry| from_h(entry) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# "model_notes_deepseek (system, 612 chars), model_notes_x (project,
|
|
37
|
+
# 80 chars)": the line /model, /stats and chi self show; "" without
|
|
38
|
+
# notes. As format.js promptNotesText (spec/shared/labels_matrix.json).
|
|
39
|
+
# @param notes [Array<PromptNote, Hash>]
|
|
40
|
+
def self.text(notes)
|
|
41
|
+
list(notes).map(&:label).join(", ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# "model_notes_deepseek (system, 612 chars)".
|
|
45
|
+
def label
|
|
46
|
+
details = [scope, chars && "#{chars} chars"].compact.reject { |part| part.to_s.empty? }
|
|
47
|
+
details.empty? ? name : "#{name} (#{details.join(", ")})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The session file's record (string keys).
|
|
51
|
+
def to_file = to_h.transform_keys(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -45,7 +45,7 @@ module Samagotchi
|
|
|
45
45
|
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
46
46
|
# :canceled and :timeout
|
|
47
47
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
48
|
-
:stopped_by, :session, keyword_init: true)
|
|
48
|
+
:stopped_by, :kept_steps, :session, keyword_init: true)
|
|
49
49
|
|
|
50
50
|
module_function
|
|
51
51
|
|
|
@@ -171,8 +171,8 @@ module Samagotchi
|
|
|
171
171
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
175
|
-
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
|
|
174
|
+
text = outcome == "failed" ? TurnNote.failure_summary(session.messages) : nil
|
|
175
|
+
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by", "kept_steps").transform_keys(&:to_sym) : {}
|
|
176
176
|
Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
|
|
177
177
|
end
|
|
178
178
|
|
|
@@ -200,20 +200,6 @@ module Samagotchi
|
|
|
200
200
|
session
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
# The summary in the failed-turn note at the conversation's tail, nil
|
|
204
|
-
# without one.
|
|
205
|
-
def failure_summary(messages)
|
|
206
|
-
list = Array(messages)
|
|
207
|
-
index = TurnNote.trailing_index(list)
|
|
208
|
-
return nil unless index
|
|
209
|
-
|
|
210
|
-
text = (list[index][:content] || list[index]["content"]).to_s
|
|
211
|
-
prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
|
|
212
|
-
return nil unless text.start_with?(prefix)
|
|
213
|
-
|
|
214
|
-
text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
|
|
215
|
-
end
|
|
216
|
-
|
|
217
203
|
# @return [Result, nil] :done with the newest reply past the cursor
|
|
218
204
|
def reply_past(id, state_dir:, cursor:)
|
|
219
205
|
file = newest_reply(id, state_dir: state_dir)
|