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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "verdict"
|
|
4
4
|
require_relative "targets"
|
|
5
5
|
require_relative "model_size"
|
|
6
|
+
require_relative "../model_match"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Guardrails
|
|
@@ -20,8 +21,9 @@ module Samagotchi
|
|
|
20
21
|
# subcommand outside the session's repo (ShellGitDirs)
|
|
21
22
|
# models: "small" (Guardrails::ModelSize, guardrails.small_models),
|
|
22
23
|
# or a glob on the bare model name or the model key, or a
|
|
23
|
-
# list of them
|
|
24
|
-
#
|
|
24
|
+
# list of them ("|"-separated or a YAML list; ModelMatch):
|
|
25
|
+
# the rule only votes for a model that matches (no model
|
|
26
|
+
# matches none; missing = every model)
|
|
25
27
|
# touches: "chi_dirs": a command names a path in chi's config, hooks,
|
|
26
28
|
# approvals or bundles dir or a .git/hooks dir (resolved;
|
|
27
29
|
# a word that can't be resolved falls back to text)
|
|
@@ -30,7 +32,12 @@ module Samagotchi
|
|
|
30
32
|
# modes: "strict", "auto" or a list of them: the rule only votes
|
|
31
33
|
# in those guardrails.mode values (missing = every mode;
|
|
32
34
|
# a rule from config.yml without it votes in every mode)
|
|
33
|
-
# memory: "remove": a memory_write call with remove: true
|
|
35
|
+
# memory: "remove": a memory_write call with remove: true;
|
|
36
|
+
# "overlay": one that writes a model overlay
|
|
37
|
+
# (current_model_only: true); "prompt": a memory_write,
|
|
38
|
+
# write or edit of a memory file that reaches the system
|
|
39
|
+
# prompt (identity, model notes, their overlays;
|
|
40
|
+
# Targets#prompt_memory?)
|
|
34
41
|
# skip_read_only: true: a shell command that only reads
|
|
35
42
|
# (ReadOnlyShell) doesn't match
|
|
36
43
|
# verdict ask|deny, reason, scopes (for an ask; default all).
|
|
@@ -59,12 +66,20 @@ module Samagotchi
|
|
|
59
66
|
return false if git && !targets.git_outside_repo?
|
|
60
67
|
return false if touches && !targets.touches_chi?
|
|
61
68
|
return false if rm && !targets.rm_outside_tmp?
|
|
62
|
-
return false if memory && !targets
|
|
69
|
+
return false if memory && !memory_matches?(targets)
|
|
63
70
|
return false if skip_read_only && targets.read_only?
|
|
64
71
|
|
|
65
72
|
true
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
def memory_matches?(targets)
|
|
76
|
+
case memory
|
|
77
|
+
when "overlay" then targets.memory_overlay?
|
|
78
|
+
when "prompt" then targets.prompt_memory?
|
|
79
|
+
else targets.memory_remove?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
68
83
|
# Whether the rule votes in guardrails.mode +mode+.
|
|
69
84
|
def for_mode?(mode) = modes.nil? || modes.include?(mode.to_s)
|
|
70
85
|
|
|
@@ -76,15 +91,8 @@ module Samagotchi
|
|
|
76
91
|
# for every model). +small+ says whether the model is a small one.
|
|
77
92
|
def for_model?(name, key, small = -> { ModelSize.small?(name, key) })
|
|
78
93
|
return true unless models
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
models.any? do |entry|
|
|
82
|
-
if entry == "small"
|
|
83
|
-
small.call
|
|
84
|
-
else
|
|
85
|
-
[name, key].compact.any? { |candidate| File.fnmatch(entry, candidate, ModelSize::GLOB_FLAGS) }
|
|
86
|
-
end
|
|
87
|
-
end
|
|
94
|
+
|
|
95
|
+
ModelMatch.match?(models, name: name, key: key, small: small)
|
|
88
96
|
end
|
|
89
97
|
|
|
90
98
|
def path_matches?(targets)
|
|
@@ -190,10 +198,11 @@ module Samagotchi
|
|
|
190
198
|
value
|
|
191
199
|
end
|
|
192
200
|
|
|
193
|
-
|
|
201
|
+
MEMORY_KINDS = %w[remove overlay prompt].freeze
|
|
202
|
+
|
|
194
203
|
def self.memory_of(value, label)
|
|
195
204
|
return nil if value.nil?
|
|
196
|
-
raise ParseError, "#{label}: memory must be remove" unless value
|
|
205
|
+
raise ParseError, "#{label}: memory must be remove, overlay or prompt" unless MEMORY_KINDS.include?(value)
|
|
197
206
|
|
|
198
207
|
value
|
|
199
208
|
end
|
|
@@ -217,15 +226,16 @@ module Samagotchi
|
|
|
217
226
|
value
|
|
218
227
|
end
|
|
219
228
|
|
|
229
|
+
# ModelMatch's grammar: a string ("|"-separated) or a list of names.
|
|
220
230
|
def self.models_of(value, label)
|
|
221
231
|
return nil if value.nil?
|
|
222
232
|
|
|
223
233
|
names = Array(value)
|
|
224
|
-
unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? }
|
|
234
|
+
unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? } && !ModelMatch.parse(value).empty?
|
|
225
235
|
raise ParseError, "#{label}: models must be small, a glob or a list of them"
|
|
226
236
|
end
|
|
227
237
|
|
|
228
|
-
|
|
238
|
+
ModelMatch.parse(value)
|
|
229
239
|
end
|
|
230
240
|
|
|
231
241
|
def self.scopes_of(value, label)
|
|
@@ -10,6 +10,8 @@ require_relative "shell_git_dirs"
|
|
|
10
10
|
require_relative "read_only_shell"
|
|
11
11
|
require_relative "shell_paths"
|
|
12
12
|
require_relative "rm_targets"
|
|
13
|
+
require_relative "protected_paths"
|
|
14
|
+
require_relative "../memory_paths"
|
|
13
15
|
|
|
14
16
|
module Samagotchi
|
|
15
17
|
module Guardrails
|
|
@@ -65,6 +67,11 @@ module Samagotchi
|
|
|
65
67
|
attr_reader :memory_remove
|
|
66
68
|
alias memory_remove? memory_remove
|
|
67
69
|
|
|
70
|
+
# @return [Boolean] a memory_write call that writes a model overlay
|
|
71
|
+
# (current_model_only: true), for a rule's memory: overlay
|
|
72
|
+
attr_reader :memory_overlay
|
|
73
|
+
alias memory_overlay? memory_overlay
|
|
74
|
+
|
|
68
75
|
# @param call [Hash] the parsed tool call
|
|
69
76
|
# @param context [Context]
|
|
70
77
|
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
@@ -104,8 +111,10 @@ module Samagotchi
|
|
|
104
111
|
label = given[:label]
|
|
105
112
|
end
|
|
106
113
|
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
114
|
+
memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
|
|
107
115
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
108
116
|
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
117
|
+
memory_overlay: memory_overlay,
|
|
109
118
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
110
119
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
111
120
|
end
|
|
@@ -209,10 +218,12 @@ module Samagotchi
|
|
|
209
218
|
|
|
210
219
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
211
220
|
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
212
|
-
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false
|
|
221
|
+
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false,
|
|
222
|
+
memory_overlay: false)
|
|
213
223
|
@acts_as = acts_as
|
|
214
224
|
@label = label
|
|
215
225
|
@memory_remove = memory_remove
|
|
226
|
+
@memory_overlay = memory_overlay
|
|
216
227
|
@repo = repo
|
|
217
228
|
@chi_dirs = chi_dirs
|
|
218
229
|
@session_root = session_root || repo_root || cwd
|
|
@@ -267,6 +278,34 @@ module Samagotchi
|
|
|
267
278
|
@read_only = !@command.nil? && ReadOnlyShell.read_only?(@command)
|
|
268
279
|
end
|
|
269
280
|
|
|
281
|
+
# A memory file that reaches the system prompt: the identity memory and
|
|
282
|
+
# the model notes (`model_notes_*`), with their overlays.
|
|
283
|
+
PROMPT_MEMORY = /\A(?:identity(?:\..+)?|model_notes_.+)\.md\z/
|
|
284
|
+
|
|
285
|
+
# Whether a path is a prompt-reaching memory file (.prompt_memory_path?),
|
|
286
|
+
# for a rule's memory: prompt.
|
|
287
|
+
def prompt_memory?
|
|
288
|
+
return @prompt_memory if defined?(@prompt_memory)
|
|
289
|
+
|
|
290
|
+
@prompt_memory = @paths.any? { |path| self.class.prompt_memory_path?(path) }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Whether +path+, resolved (symlinks, .., the case the disk has),
|
|
294
|
+
# lies right in a memories dir (the system scope or a project's) and
|
|
295
|
+
# its name, in any case, is a PROMPT_MEMORY one: on a case-insensitive
|
|
296
|
+
# disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
|
|
297
|
+
# loads.
|
|
298
|
+
def self.prompt_memory_path?(path)
|
|
299
|
+
real = ProtectedPaths.real(path)
|
|
300
|
+
return false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
|
|
301
|
+
|
|
302
|
+
dir = File.dirname(real)
|
|
303
|
+
dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
|
|
304
|
+
File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
|
|
305
|
+
rescue StandardError
|
|
306
|
+
false
|
|
307
|
+
end
|
|
308
|
+
|
|
270
309
|
# Whether the command names a path in chi's own dirs (ShellPaths:
|
|
271
310
|
# resolved paths; the text CHI_SHELL_TEXT for a word that can't be
|
|
272
311
|
# resolved); false without a command.
|
|
@@ -14,9 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# Rules that ask about chi's own config and hooks: the core
|
|
15
15
|
# ProtectedPaths asks and the guardrails bundle's shell rule; and a
|
|
16
16
|
# command source for attached context (it runs later, ungated); chi
|
|
17
|
-
# broadcast, which is the user's
|
|
18
|
-
# (
|
|
19
|
-
|
|
17
|
+
# broadcast, which is the user's; the bundle's asks before a memory
|
|
18
|
+
# that reaches the system prompt, or a model overlay, is written (a
|
|
19
|
+
# parent model would be writing a model's prompt). Only the user may
|
|
20
|
+
# allow them (ParentApprovals).
|
|
21
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
22
|
+
model-overlay-write].freeze
|
|
20
23
|
SCOPES = %w[once session repo rule].freeze
|
|
21
24
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
22
25
|
|
|
@@ -429,7 +429,12 @@ module Samagotchi
|
|
|
429
429
|
# streamed reply is not kept), so the caller can keep it, as a cancel's
|
|
430
430
|
# salvage does.
|
|
431
431
|
module FailedTurn
|
|
432
|
-
|
|
432
|
+
# +kept_steps+: set by the Engine when the turn's work stays
|
|
433
|
+
# (Engine#failed_messages): the tool results it got to; nil when it
|
|
434
|
+
# got to none, or failed in a way that rolls it back (#keeps_work?).
|
|
435
|
+
attr_accessor :partial_conversation, :kept_steps
|
|
436
|
+
|
|
437
|
+
TOOL_ROLES = %w[tool_response tool].freeze
|
|
433
438
|
|
|
434
439
|
# The innermost loop's conversation wins.
|
|
435
440
|
# @return [Exception] +error+
|
|
@@ -438,6 +443,33 @@ module Samagotchi
|
|
|
438
443
|
error.partial_conversation ||= conversation
|
|
439
444
|
error
|
|
440
445
|
end
|
|
446
|
+
|
|
447
|
+
# What a failed turn got to: +after+ (the conversation it leaves)
|
|
448
|
+
# against +before+ (the session's before it). A turn that added tool
|
|
449
|
+
# results did work worth keeping: its tool calls ran (files may have
|
|
450
|
+
# changed), and dropping them would leave the model blind to that. A
|
|
451
|
+
# model message alone isn't (a recovery nudge's, an unfinished
|
|
452
|
+
# call's). Counted by role, so notes that came meanwhile, the system
|
|
453
|
+
# head and reminders don't count.
|
|
454
|
+
# @return [Integer, nil] the tool results it added, or nil for none
|
|
455
|
+
def self.progress(before, after)
|
|
456
|
+
steps = count(after, TOOL_ROLES) - count(before, TOOL_ROLES)
|
|
457
|
+
steps.positive? ? steps : nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Whether a turn +error+ ended may keep its work. Not a context
|
|
461
|
+
# overflow: kept, the conversation would stay over the window, every
|
|
462
|
+
# later prompt failing the same way; it is rolled back, its prompt
|
|
463
|
+
# handed back, as before.
|
|
464
|
+
def self.keeps_work?(error) = !(error.respond_to?(:context_overflow?) && error.context_overflow?)
|
|
465
|
+
|
|
466
|
+
# The steps the session kept of the turn +error+ ended, or nil.
|
|
467
|
+
def self.kept_steps(error) = error.is_a?(self) && keeps_work?(error) ? error.kept_steps : nil
|
|
468
|
+
|
|
469
|
+
def self.count(messages, roles)
|
|
470
|
+
Array(messages).count { |m| roles.include?((m[:role] || m["role"]).to_s) }
|
|
471
|
+
end
|
|
472
|
+
private_class_method :count
|
|
441
473
|
end
|
|
442
474
|
end
|
|
443
475
|
end
|
|
@@ -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
|