samagotchi 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +25 -2
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +90 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bridge/card_store.rb +6 -7
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  17. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/engine.rb +91 -16
  20. data/lib/samagotchi/events.rb +8 -1
  21. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  22. data/lib/samagotchi/guardrails/rules.rb +27 -17
  23. data/lib/samagotchi/guardrails/targets.rb +40 -1
  24. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  25. data/lib/samagotchi/llm/errors.rb +33 -1
  26. data/lib/samagotchi/llm_context_command.rb +151 -0
  27. data/lib/samagotchi/llm_context_override.rb +180 -0
  28. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  29. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  31. data/lib/samagotchi/model_match.rb +54 -0
  32. data/lib/samagotchi/model_notes.rb +202 -0
  33. data/lib/samagotchi/parent_report.rb +12 -2
  34. data/lib/samagotchi/plugin/context.rb +3 -2
  35. data/lib/samagotchi/plugin/sessions.rb +3 -1
  36. data/lib/samagotchi/reply_wait.rb +3 -17
  37. data/lib/samagotchi/self_report.rb +15 -0
  38. data/lib/samagotchi/send_command.rb +22 -2
  39. data/lib/samagotchi/session.rb +29 -4
  40. data/lib/samagotchi/session_commands.rb +21 -1
  41. data/lib/samagotchi/session_manager.rb +5 -2
  42. data/lib/samagotchi/session_metrics.rb +26 -3
  43. data/lib/samagotchi/system_prompt.rb +66 -11
  44. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
  46. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  47. data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
  48. data/lib/samagotchi/terminal_ui.rb +16 -6
  49. data/lib/samagotchi/tool_runner.rb +26 -1
  50. data/lib/samagotchi/tools/memory.rb +70 -3
  51. data/lib/samagotchi/turn_flow.rb +36 -8
  52. data/lib/samagotchi/turn_note.rb +40 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +20 -1
  55. data/lib/samagotchi/web/public/app.js +40 -6
  56. data/lib/samagotchi/web/public/ctx.js +28 -5
  57. data/lib/samagotchi/web/public/format.js +5 -2
  58. data/lib/samagotchi/web/public/index.html +12 -0
  59. data/lib/samagotchi/web/public/live_turn.js +1 -0
  60. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  61. data/lib/samagotchi/web/public/session_state.js +3 -0
  62. data/lib/samagotchi/web/public/timing.js +5 -1
  63. data/lib/samagotchi/web/session_summary.rb +2 -0
  64. data/lib/samagotchi/worker.rb +24 -18
  65. metadata +7 -1
@@ -0,0 +1,202 @@
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
+ # @return [Array<Note>]
53
+ def for(name:, key:, fallback_key: nil, muted: [], small: nil)
54
+ return [] if name.nil? || name.to_s.strip.empty?
55
+
56
+ small ||= -> { Guardrails::ModelSize.small?(name, key) }
57
+ small_once = memo(small)
58
+ seen = Set.new
59
+ notes = SCOPES.flat_map do |scope|
60
+ files(scope).filter_map do |path|
61
+ next unless seen.add?(File.expand_path(path))
62
+
63
+ note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
64
+ end
65
+ end
66
+ warn_sizes(notes)
67
+ notes
68
+ end
69
+
70
+ # +text+ (+scope+'s memory index) without the lines naming a model note
71
+ # that loads for some model (its file in +scope+ has a `models:` line);
72
+ # the same object when it names none. A `model_notes_*` memory that
73
+ # isn't one keeps its line, so it doesn't vanish. +env+: the XDG env
74
+ # the scope's dir resolves from.
75
+ def filter_index(text, scope, env: ENV)
76
+ return text if text.nil? || !text.include?(PREFIX)
77
+
78
+ dir = MemoryPaths.scope_dir(scope, env: env)
79
+ names = files_in(dir).map { |path| File.basename(path) }
80
+ text.each_line.reject do |line|
81
+ name = MutedMemories.index_line_name(line).to_s
82
+ name.start_with?(PREFIX) && !name.include?(".") && names.include?("#{name}.md") &&
83
+ note_file?(File.join(dir, "#{name}.md"))
84
+ end.join
85
+ end
86
+
87
+ # Whether +path+ is a model note's file: it has a models: first line.
88
+ def note_file?(path)
89
+ File.file?(path) && !ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets)).nil?
90
+ rescue StandardError
91
+ false
92
+ end
93
+
94
+ # Forgets which warnings were given (specs).
95
+ def reset_warnings!
96
+ @warn_mutex.synchronize { @warned.clear }
97
+ end
98
+
99
+ def files(scope) = files_in(MemoryPaths.scope_dir(scope))
100
+
101
+ # The `model_notes_*.md` files in +dir+, by their names on disk: the
102
+ # prefix exactly as written (a case-insensitive disk's glob matches
103
+ # MODEL_NOTES_x.md too, which the guardrails would not see as a note).
104
+ def files_in(dir)
105
+ Dir.glob(File.join(dir, "#{PREFIX}*.md")).select { |path| File.basename(path).start_with?(PREFIX) }.sort
106
+ rescue StandardError
107
+ []
108
+ end
109
+
110
+ def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
111
+ stem = File.basename(path, ".md")
112
+ return nil if dotted?(path, stem)
113
+ return nil if MutedMemories.muted?(stem, muted)
114
+
115
+ entries = models_of(path)
116
+ return nil unless entries
117
+ return nil unless ModelMatch.match?(entries, name: name, key: key, small: small)
118
+
119
+ body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
120
+ return nil if body.empty?
121
+
122
+ body = with_overlay(body, stem, scope, [key, fallback_key])
123
+
124
+ Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ # The note's model overlay appended as memory_read appends one: the
130
+ # key's, else the fallback key's. The file is read by its own path, not
131
+ # by name through memory_read (whose names are comma lists).
132
+ def with_overlay(body, stem, scope, keys)
133
+ keys.compact.each do |overlay_key|
134
+ overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
135
+ next unless overlay && File.file?(overlay)
136
+
137
+ return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
138
+ "#{File.read(overlay, encoding: "UTF-8").strip}"
139
+ end
140
+ body
141
+ end
142
+
143
+ # A model overlay of a note (`model_notes_a.<key>.md`) is read with
144
+ # its note; any other dotted name is skipped with a warning (memory_write
145
+ # refuses it: next to a base it would read as an overlay).
146
+ def dotted?(path, stem)
147
+ return false unless stem.include?(".")
148
+
149
+ unless ModelOverlay.overlay_file?(path)
150
+ warn_once("model_note_skipped", "Warning: model note #{path} skipped: a model note's name has no dot " \
151
+ "(rename it, e.g. #{Tools::MemoryWrite.undotted(stem)}.md)", path)
152
+ end
153
+ true
154
+ end
155
+
156
+ # The entries of the file's `models:` first line; nil (warned once)
157
+ # without one.
158
+ def models_of(path)
159
+ entries = ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets))
160
+ return entries if entries
161
+
162
+ warn_once("model_note_skipped", "Warning: model note #{path} skipped: its first line must be " \
163
+ "`models: <glob>|small|…`", path)
164
+ nil
165
+ end
166
+
167
+ def warn_sizes(notes)
168
+ notes.each do |note|
169
+ next unless note.chars > NOTE_LIMIT
170
+
171
+ warn_once("model_notes_large", "Warning: model note #{note.name} (#{note.scope}) is #{note.chars} chars, over " \
172
+ "#{NOTE_LIMIT}: it is sent with every request", note.name)
173
+ end
174
+ total = notes.sum(&:chars)
175
+ return unless total > TOTAL_LIMIT
176
+
177
+ warn_once("model_notes_large", "Warning: the model notes for this model are #{total} chars, over #{TOTAL_LIMIT}: " \
178
+ "they are sent with every request", notes.map(&:name).join(","))
179
+ end
180
+
181
+ # Once per message for the process (a prompt is built again on a
182
+ # model switch, another thinking level, changed tools).
183
+ def warn_once(event, echo, memory)
184
+ first = @warn_mutex.synchronize { @warned.add?(echo) }
185
+ Log.warn(:memory, event, echo: echo, memory: memory) if first
186
+ end
187
+
188
+ def memo(callable)
189
+ value = nil
190
+ known = false
191
+ lambda do
192
+ unless known
193
+ value = callable.call
194
+ known = true
195
+ end
196
+ value
197
+ end
198
+ end
199
+
200
+ private_class_method :note_file?, :files, :files_in, :note_at, :with_overlay, :dotted?, :models_of, :warn_sizes, :warn_once, :memo
201
+ end
202
+ 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.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
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; +model_key+ is the
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
@@ -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)
@@ -20,6 +20,7 @@ require_relative "hooks/loader"
20
20
  require_relative "memory_bundle/provenance"
21
21
  require_relative "memory_bundle/manifest"
22
22
  require_relative "memory_bundle/system_bundle"
23
+ require_relative "memory_bundle/index_size"
23
24
  require_relative "desktop"
24
25
  require_relative "live_versions"
25
26
  require_relative "web/lan"
@@ -53,6 +54,7 @@ module Samagotchi
53
54
  ["hooks dir", hooks_dir(env)],
54
55
  ["memories", Tools::MemoryRead.memories_dir("system", env: env)],
55
56
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
57
+ ["memory index", memory_index_summary(env)],
56
58
  ["sessions", Session.default_state_dir(env: env)],
57
59
  ["log", log_path(env)],
58
60
  ["model", model ? model_label(model, default, env) : "(not configured)"],
@@ -100,6 +102,19 @@ module Samagotchi
100
102
  LogPath.resolve(env: env) || "(disabled: log.disable)"
101
103
  end
102
104
 
105
+ # "system ~2.0k tokens (54 lines), project ~1.4k tokens (35 lines)":
106
+ # the two indexes every prompt carries (before a session's mutes), a
107
+ # scope over memory.index_warn_tokens marked so. Two file reads. The
108
+ # indexes come from +env+'s memories dirs; the limit and
109
+ # context.chars_per_token from Config, which reads the process's ENV
110
+ # and config.yml as the rest of chi self's settings (web.port) do.
111
+ def memory_index_summary(env)
112
+ limit = MemoryBundle::IndexSize.warn_limit
113
+ %w[system project].map { |scope| MemoryBundle::IndexSize.of_scope(scope, env: env).summary(limit) }.join(", ")
114
+ rescue StandardError => e
115
+ "(unreadable: #{e.message})"
116
+ end
117
+
103
118
  def with_presence(path)
104
119
  File.exist?(path) ? path : "#{path} (missing)"
105
120
  end
@@ -33,7 +33,7 @@ module Samagotchi
33
33
 
34
34
  USAGE = <<~TEXT
35
35
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
36
- chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
36
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
37
37
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
38
38
  chi send --wait [--timeout S] [--format json] ID
39
39
  Sends a message to each session, as if typed in it: a turn starts,
@@ -53,6 +53,9 @@ module Samagotchi
53
53
  --dir DIR (--new) its folder, the project it belongs to; default
54
54
  the current one
55
55
  --model M (--new) its model; default the configured one
56
+ --llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
57
+ (--new) its own LLM context strategy (none, stale,forget),
58
+ apply rule and budget (64k, off), as chi takes them
56
59
  --wait wait for the answer and print it (one session); the
57
60
  other lines go to stderr. Exit 3: it waits for an
58
61
  answer from you: the question, its options and the
@@ -81,6 +84,9 @@ module Samagotchi
81
84
  f.switch "--new"
82
85
  f.value "--dir"
83
86
  f.value "--model"
87
+ f.value "--llm-context", key: :llm_strategy
88
+ f.value "--llm-context-apply", key: :llm_apply
89
+ f.value "--llm-context-budget", key: :llm_budget
84
90
  f.value "--image", key: :images, repeat: true
85
91
  f.switch "--wait"
86
92
  f.value "--timeout"
@@ -200,14 +206,27 @@ module Samagotchi
200
206
  return new_options(options) if options[:new]
201
207
 
202
208
  %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
209
+ LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
203
210
  return usage_error("give session ids") if options[:ids].empty?
204
211
 
205
212
  options
206
213
  end
207
214
 
215
+ # The --llm-context* option keys, and their fields.
216
+ LLM_CONTEXT_FLAGS = { llm_strategy: "--llm-context", llm_apply: "--llm-context-apply",
217
+ llm_budget: "--llm-context-budget" }.freeze
218
+ LLM_CONTEXT_FIELDS = { llm_strategy: :strategy, llm_apply: :apply, llm_budget: :budget_tokens }.freeze
219
+
208
220
  def new_options(options)
209
221
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
210
222
 
223
+ words = LLM_CONTEXT_FIELDS.filter_map { |key, field| [field, options[key]] if options[key] }.to_h
224
+ begin
225
+ options[:llm_context] = LLMContextOverride.update(nil, words) unless words.empty?
226
+ rescue ArgumentError => e
227
+ return usage_error(e.message)
228
+ end
229
+
211
230
  if options[:dir]
212
231
  options[:dir] = File.expand_path(options[:dir])
213
232
  return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
@@ -246,7 +265,8 @@ module Samagotchi
246
265
  begin
247
266
  start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
248
267
  session = SessionManager.spawn_session(**start, working_directory: options[:dir],
249
- model_name: options[:model], state_dir: @state_dir)
268
+ model_name: options[:model], llm_context: options[:llm_context],
269
+ state_dir: @state_dir)
250
270
  rescue StandardError => e
251
271
  error_line("chi send: could not start a session: #{e.message}")
252
272
  return 1
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "llm_context_override"
9
10
 
10
11
  require_relative "paths"
11
12
  require_relative "project_scope"
@@ -70,6 +71,26 @@ module Samagotchi
70
71
  # delegate children's reports (ChildReports); context: one an attached
71
72
  # context source's change started (Worker).
72
73
  attr_accessor :last_turn
74
+ # The session's own llm_context values (LLMContextOverride: strategy,
75
+ # apply rule, budget), before the model's; nil when it has none.
76
+ # Saved, so a --resume and a respawned worker keep them; a plugin's
77
+ # fork copies them, a delegate child starts without.
78
+ attr_reader :llm_context
79
+
80
+ # @param value [LLMContextOverride, Hash, nil] an empty one is nil; a
81
+ # file's Hash keeps the fields it can't read (LLMContextOverride.unread),
82
+ # saved back as they were until the session sets its own
83
+ def llm_context=(value)
84
+ @llm_context_unread = LLMContextOverride.unread(value)
85
+ @llm_context = LLMContextOverride.from_file(value)
86
+ end
87
+
88
+ # The session file's "llm_context": the override's fields over the ones
89
+ # it couldn't read; nil when there are none.
90
+ def llm_context_file
91
+ saved = @llm_context_unread.merge(@llm_context&.to_file || {})
92
+ saved.empty? ? nil : saved
93
+ end
73
94
 
74
95
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
75
96
  # left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
@@ -97,7 +118,7 @@ module Samagotchi
97
118
  first_preview: "", test_run: false, pending_question: nil,
98
119
  used_memory_names: [], project_root: nil,
99
120
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
100
- last_turn: nil, model_typed: nil, delegate: false)
121
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil)
101
122
  @id = id
102
123
  @metadata_version = metadata_version
103
124
  @mode = mode
@@ -120,6 +141,7 @@ module Samagotchi
120
141
  @scratch = !!scratch
121
142
  @last_turn = last_turn
122
143
  @delegate = !!delegate
144
+ self.llm_context = llm_context
123
145
  @archived = false
124
146
  end
125
147
 
@@ -170,7 +192,7 @@ module Samagotchi
170
192
  # seed); [] by default
171
193
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
172
194
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
173
- scratch: false, model_typed: nil, delegate: false)
195
+ scratch: false, model_typed: nil, delegate: false, llm_context: nil)
174
196
  now = Time.now.iso8601(3)
175
197
  resolved_test = if test_run.nil?
176
198
  test_session_env?
@@ -194,7 +216,8 @@ module Samagotchi
194
216
  muted_memory_names: muted_memory_names,
195
217
  parent_id: parent_id,
196
218
  scratch: scratch,
197
- delegate: delegate
219
+ delegate: delegate,
220
+ llm_context: llm_context
198
221
  )
199
222
  end
200
223
 
@@ -249,7 +272,8 @@ module Samagotchi
249
272
  "parent_id" => nil,
250
273
  "scratch" => false,
251
274
  "last_turn" => nil,
252
- "delegate" => false
275
+ "delegate" => false,
276
+ "llm_context" => nil
253
277
  }.freeze
254
278
 
255
279
  # A session from a parsed session file. +messages+ false leaves the
@@ -368,6 +392,7 @@ module Samagotchi
368
392
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
369
393
  when "test_run" then !!@test_run
370
394
  when "delegate" then @delegate
395
+ when "llm_context" then llm_context_file
371
396
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
372
397
  Array(instance_variable_get(:"@#{key}"))
373
398
  else instance_variable_get(:"@#{key}")
@@ -9,10 +9,11 @@ require_relative "tools/builtins"
9
9
  require_relative "tools/context_read"
10
10
  require_relative "tools/peers"
11
11
  require_relative "commands/registry"
12
+ require_relative "llm_context_command"
12
13
 
13
14
  module Samagotchi
14
15
  # The session commands a REPL and a session worker both run: /model,
15
- # /models, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
16
+ # /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
16
17
  # the Engine and its TurnFlow; the host prints the result's output and
17
18
  # runs a continue turn when asked to (#run never runs a turn).
18
19
  #
@@ -26,6 +27,7 @@ module Samagotchi
26
27
  GUARDRAILS_COMMAND = "/guardrails"
27
28
  CONTEXT_COMMAND = "/context"
28
29
  HELP_COMMAND = "/help"
30
+ LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
29
31
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
30
32
  # shows this many per host; /models <text> lists every match.
31
33
  MODELS_PER_HOST = 20
@@ -73,6 +75,9 @@ module Samagotchi
73
75
  end
74
76
  registry.register(MODEL_COMMAND, "show or switch the model",
75
77
  match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
78
+ # Not anytime: it saves the session; it takes effect at the next turn's start.
79
+ registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
80
+ id: :llm_context, match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
76
81
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
77
82
  registry.register("/stats", "show the session's stats", local: true)
78
83
  registry.register("/recap", "show the session's recap", local: true)
@@ -330,6 +335,21 @@ module Samagotchi
330
335
  @save.call(session)
331
336
  end
332
337
 
338
+ # /llm-context (LLMContextCommand); :llm_context in changed when it set
339
+ # the session's values.
340
+ def llm_context(text)
341
+ output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_llm_context(session) })
342
+ .run(text.delete_prefix(LLM_CONTEXT_COMMAND))
343
+ reply(output, changed: changed ? [:llm_context] : [])
344
+ rescue ArgumentError => e
345
+ reply(e.message, status: :error)
346
+ end
347
+
348
+ def save_llm_context(session)
349
+ @engine.store_model!(session)
350
+ @save.call(session)
351
+ end
352
+
333
353
  def model(text)
334
354
  output, switched = model_command(text)
335
355
  reply(output, changed: switched ? [:model] : [])
@@ -129,11 +129,13 @@ module Samagotchi
129
129
  # @param images_from [String, nil] the session dir the seed's images are in
130
130
  # @param title [String, nil] what the lists show before the first turn
131
131
  # (the prompt's preview by default, else the seed's first user message)
132
+ # @param llm_context [LLMContextOverride, nil] the session's own
133
+ # llm_context values (chi --llm-context; a fork copies its parent's)
132
134
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
133
135
  # and #model_warning: an id its host's saved list doesn't have
134
136
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
135
137
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
136
- title: nil, delegate: false)
138
+ title: nil, delegate: false, llm_context: nil)
137
139
  sd = state_dir || Session.default_state_dir
138
140
  # The resolved ref is stored (a resumed session keeps its model when an
139
141
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -153,7 +155,8 @@ module Samagotchi
153
155
  muted_memory_names: muted_memories,
154
156
  parent_id: parent_id,
155
157
  messages: messages,
156
- delegate: delegate
158
+ delegate: delegate,
159
+ llm_context: llm_context
157
160
  )
158
161
  session.model_warning = model_warning
159
162
  # With no prompt there is no first turn to run (an attaching UI sends
@@ -53,8 +53,14 @@ module Samagotchi
53
53
  # had none) and the session's tokens block as the snapshot has it.
54
54
  GenerationReport = Data.define(:speed, :tokens)
55
55
 
56
- SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum) do
57
- def tokens = to_h.except(:ctx_pct)
56
+ # memory_index: the memory indexes the session's prompt held
57
+ # ({ system: { tokens:, lines: }, project: … }, string keys as saved),
58
+ # nil when an older file has none.
59
+ SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
60
+ :memory_index) do
61
+ def initialize(memory_index: nil, **) = super
62
+
63
+ def tokens = to_h.except(:ctx_pct, :memory_index)
58
64
  end
59
65
 
60
66
  # Per-turn transient state, reset on each turn_started.
@@ -150,7 +156,8 @@ module Samagotchi
150
156
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
151
157
  SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
152
158
  completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
153
- reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"))
159
+ reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
160
+ memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
154
161
  rescue JSON::ParserError, SystemCallError, TypeError
155
162
  nil
156
163
  end
@@ -196,6 +203,7 @@ module Samagotchi
196
203
  @wall_clock = wall_clock || -> { Time.now }
197
204
  @session_id = nil
198
205
  @state_dir = nil
206
+ @memory_index = nil
199
207
  @started_at = nil
200
208
  @last_activity_at = nil
201
209
  @turn = nil
@@ -217,6 +225,14 @@ module Samagotchi
217
225
  end
218
226
  end
219
227
 
228
+ # The memory indexes the session's prompt holds (SystemPrompt#memory_index:
229
+ # { system: { tokens:, lines: }, project: … }), set by the Engine when
230
+ # it builds the prompt it sends; the snapshot and analytics.json carry it.
231
+ # @param block [Hash, nil]
232
+ def memory_index=(block)
233
+ @mutex.synchronize { @memory_index = block if block }
234
+ end
235
+
220
236
  # The sessions dir analytics.json is read from and saved to (nil: the
221
237
  # XDG default). Set it before #session_id=.
222
238
  attr_writer :state_dir
@@ -367,6 +383,7 @@ module Samagotchi
367
383
  cancellations: totals[:cancellations],
368
384
  tokens: tokens_block(totals, turn),
369
385
  context: context_block(@turn_records),
386
+ memory_index: @memory_index,
370
387
  profile: @profile,
371
388
  profile_source: @profile_source,
372
389
  served_model: @served_model,
@@ -875,6 +892,8 @@ module Samagotchi
875
892
  @served_model = prior["served_model"]
876
893
  @served_model_for = prior["served_model_for"]
877
894
  end
895
+ # The indexes the prompt last held, until this process builds its own.
896
+ @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
878
897
  # The window last seen, until this process's first generation reports.
879
898
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
880
899
  if window["window_tokens"] && @context_window_tokens.nil?
@@ -885,6 +904,10 @@ module Samagotchi
885
904
  nil
886
905
  end
887
906
 
907
+ def symbolized(hash)
908
+ hash.to_h { |key, value| [key.to_sym, value.is_a?(Hash) ? symbolized(value) : value] }
909
+ end
910
+
888
911
  def loaded_records(records)
889
912
  Array(records).filter_map { |record| record.transform_keys(&:to_sym) if record.is_a?(Hash) && record["id"] }
890
913
  end