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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +108 -1
  10. data/docs/sessions.md +5 -5
  11. data/docs/testing.md +16 -0
  12. data/lib/samagotchi/bridge/card_store.rb +6 -7
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  17. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  19. data/lib/samagotchi/child_reports.rb +8 -4
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_command.rb +5 -2
  22. data/lib/samagotchi/engine.rb +121 -16
  23. data/lib/samagotchi/events.rb +8 -1
  24. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  25. data/lib/samagotchi/guardrails/rules.rb +27 -17
  26. data/lib/samagotchi/guardrails/targets.rb +40 -1
  27. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  28. data/lib/samagotchi/llm/errors.rb +33 -1
  29. data/lib/samagotchi/llm_context_command.rb +151 -0
  30. data/lib/samagotchi/llm_context_override.rb +180 -0
  31. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  32. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  33. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  34. data/lib/samagotchi/model_match.rb +54 -0
  35. data/lib/samagotchi/model_notes.rb +205 -0
  36. data/lib/samagotchi/parent_report.rb +12 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/sessions.rb +3 -1
  39. data/lib/samagotchi/prompt_note.rb +53 -0
  40. data/lib/samagotchi/reply_wait.rb +3 -17
  41. data/lib/samagotchi/self_report.rb +47 -0
  42. data/lib/samagotchi/send_command.rb +22 -2
  43. data/lib/samagotchi/session.rb +44 -4
  44. data/lib/samagotchi/session_commands.rb +45 -6
  45. data/lib/samagotchi/session_manager.rb +5 -2
  46. data/lib/samagotchi/session_metrics.rb +47 -7
  47. data/lib/samagotchi/system_prompt.rb +79 -11
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
  50. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  51. data/lib/samagotchi/terminal_ui/formatting.rb +40 -0
  52. data/lib/samagotchi/terminal_ui.rb +16 -6
  53. data/lib/samagotchi/tool_runner.rb +26 -1
  54. data/lib/samagotchi/tools/memory.rb +70 -3
  55. data/lib/samagotchi/turn_flow.rb +44 -8
  56. data/lib/samagotchi/turn_note.rb +40 -1
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +35 -6
  59. data/lib/samagotchi/web/public/app.js +47 -7
  60. data/lib/samagotchi/web/public/ctx.js +28 -5
  61. data/lib/samagotchi/web/public/format.js +29 -2
  62. data/lib/samagotchi/web/public/index.html +12 -0
  63. data/lib/samagotchi/web/public/live_turn.js +1 -0
  64. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  65. data/lib/samagotchi/web/public/session_state.js +6 -0
  66. data/lib/samagotchi/web/public/timing.js +8 -2
  67. data/lib/samagotchi/web/public/turn_view.js +15 -2
  68. data/lib/samagotchi/web/session_summary.rb +4 -0
  69. data/lib/samagotchi/worker.rb +36 -18
  70. data/lib/samagotchi/worker_wakes.rb +2 -1
  71. 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: the rule only votes for a model that matches
24
- # (no model matches none; missing = every model)
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.memory_remove?
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
- return false if name.nil? || name.empty?
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
- # Only "remove" for now.
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 == "remove"
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
- names.map(&:strip)
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. Only the user may allow them
18
- # (ParentApprovals).
19
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast].freeze
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
- attr_accessor :partial_conversation
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