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
@@ -7,8 +7,8 @@ module Samagotchi
7
7
  # llm_context_strategy (models.<key>, hosts.<name>), each a layer name,
8
8
  # "|"-separated names or a YAML list.
9
9
  #
10
- # Resolution, first set wins: the session's own (a later per-session
11
- # override; nothing sets it yet), models.<key> (by the model's lookup
10
+ # Resolution, first set wins: the session's own (Session#llm_context,
11
+ # set by chi --llm-context, /llm-context and the web), models.<key> (by the model's lookup
12
12
  # names, as window_tokens), hosts.<name>, then llm_context.strategy.
13
13
  # An unknown layer warns and is none (every layer is built: stale, and
14
14
  # forget, experimental, with its tool forget_outputs).
@@ -118,29 +118,71 @@ module Samagotchi
118
118
  nil
119
119
  end
120
120
 
121
+ # Where one value came from: :session, :model_setting, :host_setting
122
+ # or :config, and the place as a warning names it ("the session",
123
+ # "models: deepseek", "hosts entry 'box'", "llm_context.apply").
124
+ Origin = Data.define(:source, :where)
125
+ SESSION_ORIGIN = Origin.new(source: :session, where: "the session")
126
+
127
+ # What a turn's strategy, apply rule and budget are, each with its
128
+ # Origin (/llm-context, /stats, the web's info bar).
129
+ Explained = Data.define(:resolved, :strategy, :apply, :budget_tokens) do
130
+ # The plain form /stats, the snapshot and the web read: each value
131
+ # with its source and place, and +own+, the session's own values
132
+ # (LLMContextOverride#to_file; nil without any).
133
+ # @return [Hash] symbol keys
134
+ def summary(own = nil)
135
+ layers = resolved.active_layers
136
+ { strategy: layers.empty? ? NONE.to_s : layers.join(","), strategy_source: strategy.source.to_s,
137
+ strategy_where: strategy.where, apply: resolved.apply.to_s, apply_source: apply.source.to_s,
138
+ apply_where: apply.where, budget_tokens: resolved.budget_tokens, budget_source: budget_tokens.source.to_s,
139
+ budget_where: budget_tokens.where, own: own&.to_file }
140
+ end
141
+ end
142
+
121
143
  # The strategy for +target+'s turn.
122
144
  # @param target [HostRegistry::ModelTarget, nil]
123
145
  # @param names [Array<String>] HostRegistry#lookup_names
124
146
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
125
- # @param session [Array<Symbol>, nil] the session's own layers (none yet)
126
- # @param session_apply [Symbol, nil] the session's own apply rule (none yet)
127
- # @param session_budget [Integer, nil] the session's own budget (none yet)
147
+ # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
148
+ # @param session_apply [Symbol, nil] the session's own apply rule
149
+ # @param session_budget [Integer, nil] the session's own budget (0: off)
128
150
  # @return [Resolved]
129
151
  def resolve(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
152
+ explain(target, names: names, models: models, session: session, session_apply: session_apply,
153
+ session_budget: session_budget).resolved
154
+ end
155
+
156
+ # #resolve, with where each value came from.
157
+ # @return [Explained]
158
+ def explain(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
130
159
  models ||= ConfigFile.model_settings
131
160
  layers, source, where = layers_for(target, names, models, session)
132
- resolved(layers, source, where).with(apply: apply_for(target, names, models, session_apply),
133
- protect_steps: protect_steps, stale_edits: Config.get(STALE_EDITS_SETTING) == true,
134
- budget_tokens: budget_for(target, names, models, session_budget))
161
+ apply, apply_origin = apply_for(target, names, models, session_apply)
162
+ budget, budget_origin = budget_for(target, names, models, session_budget)
163
+ resolved = resolved(layers, source, where).with(apply: apply, protect_steps: protect_steps,
164
+ stale_edits: Config.get(STALE_EDITS_SETTING) == true,
165
+ budget_tokens: budget)
166
+ Explained.new(resolved: resolved, strategy: Origin.new(source: source, where: where), apply: apply_origin,
167
+ budget_tokens: budget_origin)
135
168
  end
136
169
 
170
+ # [the budget, its Origin]: the session's own first (0 there is off,
171
+ # whatever the model says).
137
172
  def budget_for(target, names, models, session_budget)
138
- session_budget || ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)&.last ||
139
- target&.entry&.llm_context_budget_tokens || parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING)
173
+ return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
174
+
175
+ key, budget = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)
176
+ return [budget, Origin.new(source: :model_setting, where: "models: #{key}")] if budget
177
+
178
+ budget = target&.entry&.llm_context_budget_tokens
179
+ return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
180
+
181
+ [parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING), Origin.new(source: :config, where: BUDGET_SETTING)]
140
182
  end
141
183
 
142
184
  def layers_for(target, names, models, session)
143
- return [session, :session, "the session"] if session
185
+ return [session, :session, SESSION_ORIGIN.where] if session
144
186
 
145
187
  key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
146
188
  return [layers, :model_setting, "models: #{key}"] if layers
@@ -151,9 +193,17 @@ module Samagotchi
151
193
  [parse(Config.get(SETTING), SETTING) || [], :config, SETTING]
152
194
  end
153
195
 
196
+ # [the apply rule, its Origin].
154
197
  def apply_for(target, names, models, session_apply)
155
- session_apply || ConfigFile.model_setting(names, :llm_context_apply, models: models)&.last ||
156
- target&.entry&.llm_context_apply || parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY
198
+ return [session_apply, SESSION_ORIGIN] if session_apply
199
+
200
+ key, apply = ConfigFile.model_setting(names, :llm_context_apply, models: models)
201
+ return [apply, Origin.new(source: :model_setting, where: "models: #{key}")] if apply
202
+
203
+ apply = target&.entry&.llm_context_apply
204
+ return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
205
+
206
+ [parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY, Origin.new(source: :config, where: APPLY_SETTING)]
157
207
  end
158
208
 
159
209
  # llm_context.protect_steps: 0 or more (a negative one is the default).
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../tools/memory"
5
+ require_relative "../model_notes"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # The size of one scope's memory index as a prompt carries it: every
10
+ # session's system prompt holds both indexes in full, so their size is
11
+ # paid on every request. tokens is the estimate ContextStatus uses
12
+ # (characters / context.chars_per_token, rounded up), lines the
13
+ # non-blank lines, bytes the UTF-8 size.
14
+ #
15
+ # memory_write and write/edit on a memory compare the size before and
16
+ # after a write against memory.index_warn_tokens (#crossing_note);
17
+ # chi self shows it (#summary), and a session's figure comes from the
18
+ # index text its prompt holds (SystemPrompt).
19
+ IndexSize = Data.define(:scope, :bytes, :lines, :tokens) do
20
+ # @param text [String, nil] the index text
21
+ # @param chars_per_token [Float]
22
+ # @return [IndexSize]
23
+ def self.of_text(scope, text, chars_per_token: self.chars_per_token)
24
+ text = text.to_s
25
+ new(scope: scope.to_s, bytes: text.bytesize, lines: text.each_line.count { |line| !line.strip.empty? },
26
+ tokens: (text.length / chars_per_token).ceil)
27
+ end
28
+
29
+ # +scope+'s index as the prompt carries it before a session's own
30
+ # filters (mutes): index.md, or the file listing without one, minus
31
+ # the model notes' lines (ModelNotes.filter_index).
32
+ # @param env [Hash] the XDG env the memories dir resolves from
33
+ # @return [IndexSize]
34
+ def self.of_scope(scope, chars_per_token: self.chars_per_token, env: ENV)
35
+ text = Tools::MemoryRead.scoped_index(scope, env: env)
36
+ of_text(scope, ModelNotes.filter_index(text, scope, env: env), chars_per_token: chars_per_token)
37
+ end
38
+
39
+ # of_scope, or nil when the index can't be read (a write's measure
40
+ # must never fail the write).
41
+ def self.measure(scope)
42
+ of_scope(scope)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # context.chars_per_token, 4.0 when it isn't positive.
48
+ def self.chars_per_token
49
+ value = Config.get("context.chars_per_token").to_f
50
+ value.positive? ? value : 4.0
51
+ rescue StandardError
52
+ 4.0
53
+ end
54
+
55
+ # memory.index_warn_tokens: the tokens one scope's index may hold
56
+ # before a write that crosses it gets a note; 0 turns the note off.
57
+ def self.warn_limit
58
+ Config.get("memory.index_warn_tokens").to_i
59
+ rescue StandardError
60
+ 0
61
+ end
62
+
63
+ # The note a write gets when it took the index from at or under
64
+ # +limit+ to over it, nil otherwise: once per crossing, so a write
65
+ # while the index is already over gets none and the note doesn't
66
+ # start a loop of small tightenings. Sessions sharing the dir measure
67
+ # on their own (before and after aren't one locked step), so writes
68
+ # in parallel sessions racing over the limit may each get it.
69
+ # @param before [IndexSize, nil]
70
+ # @param after [IndexSize, nil]
71
+ # @return [String, nil]
72
+ def self.crossing_note(before, after, limit = warn_limit)
73
+ return nil unless before && after && after.over?(limit) && !before.over?(limit)
74
+
75
+ "Note: the #{after.scope} memory index is now ~#{after.tokens} tokens (over memory.index_warn_tokens " \
76
+ "#{limit}) and is sent with every prompt. When you next have a moment, tighten long index " \
77
+ "descriptions (memory_write with name, scope and description only). Don't remove or merge memories " \
78
+ "unless the user asks."
79
+ end
80
+
81
+ # "450", "2.0k": a token count as the size readouts show it (the
82
+ # web's ctx.js memoryIndexText too; spec/shared/labels_matrix.json).
83
+ def self.count_text(tokens)
84
+ value = tokens.to_i
85
+ value >= 1000 ? "#{format("%.1f", (value / 100.0).round / 10.0)}k" : value.to_s
86
+ end
87
+
88
+ # Whether the index holds more than +limit+ tokens; a limit that
89
+ # isn't positive is off.
90
+ def over?(limit) = limit.to_i.positive? && tokens > limit.to_i
91
+
92
+ # "system ~2.0k tokens (54 lines)", with ", over 2500" in the
93
+ # parentheses when over +limit+: chi self's row.
94
+ def summary(limit = 0)
95
+ over = over?(limit) ? ", over #{limit}" : ""
96
+ "#{scope} ~#{self.class.count_text(tokens)} tokens (#{lines} #{lines == 1 ? "line" : "lines"}#{over})"
97
+ end
98
+
99
+ # { tokens:, lines: }: the session's block (SessionMetrics).
100
+ def figures = { tokens: tokens, lines: lines }
101
+ end
102
+ end
103
+ end
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # "project" or "system" when +path+ is a memory's file (or an
41
- # overlay's): a *.md right in that scope's memories dir, not index.md,
41
+ # overlay's): a *.md right in that scope's memories dir, not index.md (in any case),
42
42
  # not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
43
43
  # uses it too: write/edit there is what memory_write does. A symlink
44
44
  # isn't: write/edit follow it to wherever it points, memory_write
@@ -47,7 +47,8 @@ module Samagotchi
47
47
  path = File.expand_path(path.to_s)
48
48
  name = File.basename(path, ".md")
49
49
  return nil unless File.extname(path) == ".md"
50
- return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
50
+ # Any case: on a case-insensitive disk INDEX.md is the index itself.
51
+ return nil if name.casecmp?(IndexUpdater::MEMORY_INDEX) || name.start_with?(".") || File.symlink?(path)
51
52
 
52
53
  dir = real(File.dirname(path))
53
54
  SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Which models a `models:` list is for, in one grammar shared by the
5
+ # guardrails rules' `models:`, `guardrails.small_models` and the model
6
+ # notes' first line: entries `|`-separated (or a list), each `small` (the
7
+ # caller says whether the model is a small one) or an fnmatch glob
8
+ # (case-insensitive, extglob) on the bare model name or the model key
9
+ # (ModelOverlay.key_for). Any entry matching wins; no model matches
10
+ # nothing.
11
+ module ModelMatch
12
+ GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
13
+ SMALL = "small"
14
+ # A model note's first line (ModelNotes): `models: …`, any case, after
15
+ # an optional UTF-8 BOM.
16
+ MODELS_LINE = /\A\uFEFF?\s*models:(.*)\z/i
17
+
18
+ module_function
19
+
20
+ # The entries of a `models:` value: a string, or a list of them, each
21
+ # split on `|`, stripped, blanks dropped.
22
+ # @param value [String, Array<String>, nil]
23
+ # @return [Array<String>]
24
+ def parse(value)
25
+ Array(value).flat_map { |entry| entry.to_s.split("|") }.map(&:strip).reject(&:empty?)
26
+ end
27
+
28
+ # The entries of +text+'s first line when it is a `models:` line with
29
+ # at least one; nil otherwise.
30
+ # @return [Array<String>, nil]
31
+ def models_line(text)
32
+ first = text.to_s.each_line.first.to_s.chomp
33
+ match = first.match(MODELS_LINE)
34
+ entries = match && parse(match[1])
35
+ entries unless entries.nil? || entries.empty?
36
+ end
37
+
38
+ # @param entries [Array<String>] parsed entries (#parse)
39
+ # @param name [String, nil] the bare model name
40
+ # @param key [String, nil] its model key
41
+ # @param small [#call] → whether the model is a small one; called only
42
+ # for a `small` entry
43
+ def match?(entries, name:, key:, small:)
44
+ return false if name.nil? || name.to_s.empty?
45
+
46
+ entries.any? { |entry| entry == SMALL ? small.call : glob?(entry, name: name, key: key) }
47
+ end
48
+
49
+ # Whether the glob +entry+ matches the name or the key.
50
+ def glob?(entry, name:, key:)
51
+ [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "log"
5
+ require_relative "model_match"
6
+ require_relative "model_overlay"
7
+ require_relative "memory_paths"
8
+ require_relative "muted_memories"
9
+ require_relative "tools/memory"
10
+ require_relative "guardrails/model_size"
11
+
12
+ module Samagotchi
13
+ # Model notes: memories named `model_notes_<name>` whose first line says
14
+ # which models they are for, in ModelMatch's grammar:
15
+ #
16
+ # models: deepseek-*|*deepseek-v4*
17
+ # Working habits for this model: ...
18
+ #
19
+ # Every note matching the session's model is loaded into the system
20
+ # prompt (SystemPrompt#model_notes_section, after identity), stacked: the
21
+ # system scope's, then the project's, by name within a scope. Notes add
22
+ # habits; none overrides another. The body is read through memory_read,
23
+ # so a note's own model overlays are appended as for any memory; the
24
+ # models: line is left out. Their index lines are left out of the
25
+ # prompt's indexes (#filter_index): the body is already there.
26
+ module ModelNotes
27
+ PREFIX = Tools::MODEL_NOTES_PREFIX
28
+ SCOPES = %w[system project].freeze
29
+ # A note over this, or all of them over TOTAL_LIMIT, still loads but
30
+ # warns once: they cost every request.
31
+ NOTE_LIMIT = 1_500
32
+ TOTAL_LIMIT = 3_000
33
+
34
+ # name: the memory name; scope: "system" or "project"; body: what the
35
+ # prompt carries (overlays appended, the models: line dropped); chars:
36
+ # its length; digest: a short SHA-256 of it.
37
+ Note = Data.define(:name, :scope, :body, :chars, :digest)
38
+
39
+ @warned = Set.new
40
+ @warn_mutex = Mutex.new
41
+
42
+ module_function
43
+
44
+ # The notes for a model.
45
+ # @param name [String, nil] the bare model id (no host prefix)
46
+ # @param key [String, nil] its model key (ModelOverlay.key_for)
47
+ # @param fallback_key [String, nil] the overlay key read when +key+ has
48
+ # none (SystemPrompt::ModelIdentity#fallback_key)
49
+ # @param muted [Array<String>] the session's muted names (MutedMemories)
50
+ # @param small [#call, nil] → whether the model is a small one; nil:
51
+ # guardrails.small_models (Guardrails::ModelSize)
52
+ # @param env [Hash] the XDG env the memories dirs resolve from
53
+ # @param warn [Boolean] false: no size warnings (a read-only report such
54
+ # as chi self; the prompt's build gives them)
55
+ # @return [Array<Note>]
56
+ def for(name:, key:, fallback_key: nil, muted: [], small: nil, env: ENV, warn: true)
57
+ return [] if name.nil? || name.to_s.strip.empty?
58
+
59
+ small ||= -> { Guardrails::ModelSize.small?(name, key) }
60
+ small_once = memo(small)
61
+ seen = Set.new
62
+ notes = SCOPES.flat_map do |scope|
63
+ files(scope, env).filter_map do |path|
64
+ next unless seen.add?(File.expand_path(path))
65
+
66
+ note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
67
+ end
68
+ end
69
+ warn_sizes(notes) if warn
70
+ notes
71
+ end
72
+
73
+ # +text+ (+scope+'s memory index) without the lines naming a model note
74
+ # that loads for some model (its file in +scope+ has a `models:` line);
75
+ # the same object when it names none. A `model_notes_*` memory that
76
+ # isn't one keeps its line, so it doesn't vanish. +env+: the XDG env
77
+ # the scope's dir resolves from.
78
+ def filter_index(text, scope, env: ENV)
79
+ return text if text.nil? || !text.include?(PREFIX)
80
+
81
+ dir = MemoryPaths.scope_dir(scope, env: env)
82
+ names = files_in(dir).map { |path| File.basename(path) }
83
+ text.each_line.reject do |line|
84
+ name = MutedMemories.index_line_name(line).to_s
85
+ name.start_with?(PREFIX) && !name.include?(".") && names.include?("#{name}.md") &&
86
+ note_file?(File.join(dir, "#{name}.md"))
87
+ end.join
88
+ end
89
+
90
+ # Whether +path+ is a model note's file: it has a models: first line.
91
+ def note_file?(path)
92
+ File.file?(path) && !ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets)).nil?
93
+ rescue StandardError
94
+ false
95
+ end
96
+
97
+ # Forgets which warnings were given (specs).
98
+ def reset_warnings!
99
+ @warn_mutex.synchronize { @warned.clear }
100
+ end
101
+
102
+ def files(scope, env = ENV) = files_in(MemoryPaths.scope_dir(scope, env: env))
103
+
104
+ # The `model_notes_*.md` files in +dir+, by their names on disk: the
105
+ # prefix exactly as written (a case-insensitive disk's glob matches
106
+ # MODEL_NOTES_x.md too, which the guardrails would not see as a note).
107
+ def files_in(dir)
108
+ Dir.glob(File.join(dir, "#{PREFIX}*.md")).select { |path| File.basename(path).start_with?(PREFIX) }.sort
109
+ rescue StandardError
110
+ []
111
+ end
112
+
113
+ def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
114
+ stem = File.basename(path, ".md")
115
+ return nil if dotted?(path, stem)
116
+ return nil if MutedMemories.muted?(stem, muted)
117
+
118
+ entries = models_of(path)
119
+ return nil unless entries
120
+ return nil unless ModelMatch.match?(entries, name: name, key: key, small: small)
121
+
122
+ body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
123
+ return nil if body.empty?
124
+
125
+ body = with_overlay(body, stem, scope, [key, fallback_key])
126
+
127
+ Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
128
+ rescue StandardError
129
+ nil
130
+ end
131
+
132
+ # The note's model overlay appended as memory_read appends one: the
133
+ # key's, else the fallback key's. The file is read by its own path, not
134
+ # by name through memory_read (whose names are comma lists).
135
+ def with_overlay(body, stem, scope, keys)
136
+ keys.compact.each do |overlay_key|
137
+ overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
138
+ next unless overlay && File.file?(overlay)
139
+
140
+ return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
141
+ "#{File.read(overlay, encoding: "UTF-8").strip}"
142
+ end
143
+ body
144
+ end
145
+
146
+ # A model overlay of a note (`model_notes_a.<key>.md`) is read with
147
+ # its note; any other dotted name is skipped with a warning (memory_write
148
+ # refuses it: next to a base it would read as an overlay).
149
+ def dotted?(path, stem)
150
+ return false unless stem.include?(".")
151
+
152
+ unless ModelOverlay.overlay_file?(path)
153
+ warn_once("model_note_skipped", "Warning: model note #{path} skipped: a model note's name has no dot " \
154
+ "(rename it, e.g. #{Tools::MemoryWrite.undotted(stem)}.md)", path)
155
+ end
156
+ true
157
+ end
158
+
159
+ # The entries of the file's `models:` first line; nil (warned once)
160
+ # without one.
161
+ def models_of(path)
162
+ entries = ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets))
163
+ return entries if entries
164
+
165
+ warn_once("model_note_skipped", "Warning: model note #{path} skipped: its first line must be " \
166
+ "`models: <glob>|small|…`", path)
167
+ nil
168
+ end
169
+
170
+ def warn_sizes(notes)
171
+ notes.each do |note|
172
+ next unless note.chars > NOTE_LIMIT
173
+
174
+ warn_once("model_notes_large", "Warning: model note #{note.name} (#{note.scope}) is #{note.chars} chars, over " \
175
+ "#{NOTE_LIMIT}: it is sent with every request", note.name)
176
+ end
177
+ total = notes.sum(&:chars)
178
+ return unless total > TOTAL_LIMIT
179
+
180
+ warn_once("model_notes_large", "Warning: the model notes for this model are #{total} chars, over #{TOTAL_LIMIT}: " \
181
+ "they are sent with every request", notes.map(&:name).join(","))
182
+ end
183
+
184
+ # Once per message for the process (a prompt is built again on a
185
+ # model switch, another thinking level, changed tools).
186
+ def warn_once(event, echo, memory)
187
+ first = @warn_mutex.synchronize { @warned.add?(echo) }
188
+ Log.warn(:memory, event, echo: echo, memory: memory) if first
189
+ end
190
+
191
+ def memo(callable)
192
+ value = nil
193
+ known = false
194
+ lambda do
195
+ unless known
196
+ value = callable.call
197
+ known = true
198
+ end
199
+ value
200
+ end
201
+ end
202
+
203
+ private_class_method :note_file?, :files, :files_in, :note_at, :with_overlay, :dotted?, :models_of, :warn_sizes, :warn_once, :memo
204
+ end
205
+ end
@@ -94,7 +94,7 @@ module Samagotchi
94
94
  # @return [Hash]
95
95
  def stop_facts(result)
96
96
  case status(result)
97
- when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
97
+ when "failed" then { error_kind: result.error_kind, retryable: result.retryable, kept_steps: result.kept_steps }.compact
98
98
  when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
99
99
  else {}
100
100
  end
@@ -242,7 +242,7 @@ module Samagotchi
242
242
 
243
243
  def no_reply_line(result, session_id = nil)
244
244
  case result.outcome
245
- when "failed" then result.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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A model note a session's system prompt carried (ModelNotes::Note
5
+ # without its body): what the session file's `prompt_notes` records at
6
+ # each prompt build, so /stats, /model, the web and a later comparison
7
+ # read what the prompt really held. name: the memory name; scope:
8
+ # "system" or "project"; chars: the body's length in the prompt; digest:
9
+ # a short SHA-256 of that body (ModelNotes::Note#digest).
10
+ PromptNote = Data.define(:name, :scope, :chars, :digest) do
11
+ # @param note [ModelNotes::Note]
12
+ def self.from_note(note)
13
+ new(name: note.name, scope: note.scope, chars: note.chars, digest: note.digest)
14
+ end
15
+
16
+ # A saved or wire record (string or symbol keys); nil without a name.
17
+ def self.from_h(raw)
18
+ return raw if raw.is_a?(PromptNote)
19
+ return nil unless raw.is_a?(Hash)
20
+
21
+ get = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key] }
22
+ name = get.call(:name).to_s.strip
23
+ return nil if name.empty?
24
+
25
+ chars = get.call(:chars)
26
+ new(name: name, scope: get.call(:scope)&.to_s, chars: chars.is_a?(Numeric) ? chars.to_i : nil,
27
+ digest: get.call(:digest)&.to_s)
28
+ end
29
+
30
+ # The records of a saved list (others dropped).
31
+ # @return [Array<PromptNote>]
32
+ def self.list(raw)
33
+ Array(raw).filter_map { |entry| from_h(entry) }
34
+ end
35
+
36
+ # "model_notes_deepseek (system, 612 chars), model_notes_x (project,
37
+ # 80 chars)": the line /model, /stats and chi self show; "" without
38
+ # notes. As format.js promptNotesText (spec/shared/labels_matrix.json).
39
+ # @param notes [Array<PromptNote, Hash>]
40
+ def self.text(notes)
41
+ list(notes).map(&:label).join(", ")
42
+ end
43
+
44
+ # "model_notes_deepseek (system, 612 chars)".
45
+ def label
46
+ details = [scope, chars && "#{chars} chars"].compact.reject { |part| part.to_s.empty? }
47
+ details.empty? ? name : "#{name} (#{details.join(", ")})"
48
+ end
49
+
50
+ # The session file's record (string keys).
51
+ def to_file = to_h.transform_keys(&:to_s)
52
+ end
53
+ end
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  # decided the outcome loaded it (a caller's next baseline); nil for
46
46
  # :canceled and :timeout
47
47
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
48
- :stopped_by, :session, keyword_init: true)
48
+ :stopped_by, :kept_steps, :session, keyword_init: true)
49
49
 
50
50
  module_function
51
51
 
@@ -171,8 +171,8 @@ module Samagotchi
171
171
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
172
172
  end
173
173
 
174
- text = outcome == "failed" ? failure_summary(session.messages) : nil
175
- why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
174
+ text = outcome == "failed" ? TurnNote.failure_summary(session.messages) : nil
175
+ why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by", "kept_steps").transform_keys(&:to_sym) : {}
176
176
  Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
177
177
  end
178
178
 
@@ -200,20 +200,6 @@ module Samagotchi
200
200
  session
201
201
  end
202
202
 
203
- # The summary in the failed-turn note at the conversation's tail, nil
204
- # without one.
205
- def failure_summary(messages)
206
- list = Array(messages)
207
- index = TurnNote.trailing_index(list)
208
- return nil unless index
209
-
210
- text = (list[index][:content] || list[index]["content"]).to_s
211
- prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
212
- return nil unless text.start_with?(prefix)
213
-
214
- text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
215
- end
216
-
217
203
  # @return [Result, nil] :done with the newest reply past the cursor
218
204
  def reply_past(id, state_dir:, cursor:)
219
205
  file = newest_reply(id, state_dir: state_dir)