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
@@ -10,6 +10,8 @@ require_relative "session"
10
10
  require_relative "log_path"
11
11
  require_relative "model_profile"
12
12
  require_relative "model_overlay"
13
+ require_relative "model_notes"
14
+ require_relative "prompt_note"
13
15
  require_relative "thinking"
14
16
  require_relative "served_model"
15
17
  require_relative "host_registry"
@@ -20,6 +22,7 @@ require_relative "hooks/loader"
20
22
  require_relative "memory_bundle/provenance"
21
23
  require_relative "memory_bundle/manifest"
22
24
  require_relative "memory_bundle/system_bundle"
25
+ require_relative "memory_bundle/index_size"
23
26
  require_relative "desktop"
24
27
  require_relative "live_versions"
25
28
  require_relative "web/lan"
@@ -53,10 +56,12 @@ module Samagotchi
53
56
  ["hooks dir", hooks_dir(env)],
54
57
  ["memories", Tools::MemoryRead.memories_dir("system", env: env)],
55
58
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
59
+ ["memory index", memory_index_summary(env)],
56
60
  ["sessions", Session.default_state_dir(env: env)],
57
61
  ["log", log_path(env)],
58
62
  ["model", model ? model_label(model, default, env) : "(not configured)"],
59
63
  ["model key", model ? model_key_for(model, env) : "-"],
64
+ ["model notes", model ? model_notes_for(model, env) : "-"],
60
65
  ["host", model ? host_for(model, env) : "-"],
61
66
  ["api key", model ? api_key_for(model, env) : "-"],
62
67
  ["loop", model ? loop_for(model, env) : "-"],
@@ -100,6 +105,19 @@ module Samagotchi
100
105
  LogPath.resolve(env: env) || "(disabled: log.disable)"
101
106
  end
102
107
 
108
+ # "system ~2.0k tokens (54 lines), project ~1.4k tokens (35 lines)":
109
+ # the two indexes every prompt carries (before a session's mutes), a
110
+ # scope over memory.index_warn_tokens marked so. Two file reads. The
111
+ # indexes come from +env+'s memories dirs; the limit and
112
+ # context.chars_per_token from Config, which reads the process's ENV
113
+ # and config.yml as the rest of chi self's settings (web.port) do.
114
+ def memory_index_summary(env)
115
+ limit = MemoryBundle::IndexSize.warn_limit
116
+ %w[system project].map { |scope| MemoryBundle::IndexSize.of_scope(scope, env: env).summary(limit) }.join(", ")
117
+ rescue StandardError => e
118
+ "(unreadable: #{e.message})"
119
+ end
120
+
103
121
  def with_presence(path)
104
122
  File.exist?(path) ? path : "#{path} (missing)"
105
123
  end
@@ -172,6 +190,35 @@ module Samagotchi
172
190
  ModelOverlay.key_for(model) || "-"
173
191
  end
174
192
 
193
+ # "model_notes_deepseek (system, 612 chars)": the model notes a prompt
194
+ # of +model+ loads (ModelNotes: matched on its bare id and key, the
195
+ # session's --mute list left out when chi self runs in one), "none"
196
+ # without any. Read from the memories dirs now: a session's prompt
197
+ # takes them at its next build (/stats names the ones it carries).
198
+ def model_notes_for(model, env)
199
+ bare = HostRegistry.new(env: env).bare_name(model)
200
+ notes = ModelNotes.for(name: bare, key: ModelOverlay.key_for(bare), muted: session_muted(env), env: env,
201
+ warn: false)
202
+ text = PromptNote.text(notes.map { |note| PromptNote.from_note(note) })
203
+ text.empty? ? "none" : text
204
+ rescue StandardError => e
205
+ "(unreadable: #{e.message})"
206
+ end
207
+
208
+ # The --mute list of the session chi self runs in (its execute exports
209
+ # SAMAGOTCHI_PARENT_SESSION); [] outside one.
210
+ def session_muted(env)
211
+ id = env[PARENT_SESSION_ENV].to_s
212
+ return [] if id.empty? || !Session.valid_id?(id)
213
+
214
+ state_dir = Session.default_state_dir(env: env)
215
+ return [] unless Session.exist?(id, state_dir: state_dir)
216
+
217
+ MutedMemories.normalize_list(Session.load(id, state_dir: state_dir).muted_memory_names)
218
+ rescue StandardError
219
+ []
220
+ end
221
+
175
222
  def model_name
176
223
  ModelProfile.required_model_name
177
224
  rescue ArgumentError
@@ -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,8 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "llm_context_override"
10
+ require_relative "prompt_note"
9
11
 
10
12
  require_relative "paths"
11
13
  require_relative "project_scope"
@@ -43,6 +45,17 @@ module Samagotchi
43
45
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
44
46
  # Names as given; the engine normalizes them.
45
47
  attr_accessor :preloaded_memory_names, :muted_memory_names
48
+ # The model notes the session's system prompt carried at its last build
49
+ # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
50
+ # Engine at each build (a start, a resume's or a woken worker's first
51
+ # turn, /model) and saved, so what a session ran with can be read later.
52
+ attr_reader :prompt_notes
53
+
54
+ # @param notes [Array<PromptNote, Hash>] a file's hashes are read
55
+ # (PromptNote.list)
56
+ def prompt_notes=(notes)
57
+ @prompt_notes = PromptNote.list(notes)
58
+ end
46
59
  # The name model_name was typed as when that differs from the resolved
47
60
  # ref stored in model_name (an alias: `small` for `gemma-small`), else
48
61
  # nil. The models: lookup takes it (HostRegistry#lookup_names).
@@ -70,6 +83,26 @@ module Samagotchi
70
83
  # delegate children's reports (ChildReports); context: one an attached
71
84
  # context source's change started (Worker).
72
85
  attr_accessor :last_turn
86
+ # The session's own llm_context values (LLMContextOverride: strategy,
87
+ # apply rule, budget), before the model's; nil when it has none.
88
+ # Saved, so a --resume and a respawned worker keep them; a plugin's
89
+ # fork copies them, a delegate child starts without.
90
+ attr_reader :llm_context
91
+
92
+ # @param value [LLMContextOverride, Hash, nil] an empty one is nil; a
93
+ # file's Hash keeps the fields it can't read (LLMContextOverride.unread),
94
+ # saved back as they were until the session sets its own
95
+ def llm_context=(value)
96
+ @llm_context_unread = LLMContextOverride.unread(value)
97
+ @llm_context = LLMContextOverride.from_file(value)
98
+ end
99
+
100
+ # The session file's "llm_context": the override's fields over the ones
101
+ # it couldn't read; nil when there are none.
102
+ def llm_context_file
103
+ saved = @llm_context_unread.merge(@llm_context&.to_file || {})
104
+ saved.empty? ? nil : saved
105
+ end
73
106
 
74
107
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
75
108
  # left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
@@ -97,7 +130,7 @@ module Samagotchi
97
130
  first_preview: "", test_run: false, pending_question: nil,
98
131
  used_memory_names: [], project_root: nil,
99
132
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
100
- last_turn: nil, model_typed: nil, delegate: false)
133
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [])
101
134
  @id = id
102
135
  @metadata_version = metadata_version
103
136
  @mode = mode
@@ -115,11 +148,13 @@ module Samagotchi
115
148
  @used_memory_names = self.class.name_list(used_memory_names)
116
149
  @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
117
150
  @muted_memory_names = self.class.name_list(muted_memory_names)
151
+ self.prompt_notes = prompt_notes
118
152
  @project_root = project_root
119
153
  @parent_id = parent_id&.to_s
120
154
  @scratch = !!scratch
121
155
  @last_turn = last_turn
122
156
  @delegate = !!delegate
157
+ self.llm_context = llm_context
123
158
  @archived = false
124
159
  end
125
160
 
@@ -170,7 +205,7 @@ module Samagotchi
170
205
  # seed); [] by default
171
206
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
172
207
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
173
- scratch: false, model_typed: nil, delegate: false)
208
+ scratch: false, model_typed: nil, delegate: false, llm_context: nil)
174
209
  now = Time.now.iso8601(3)
175
210
  resolved_test = if test_run.nil?
176
211
  test_session_env?
@@ -194,7 +229,8 @@ module Samagotchi
194
229
  muted_memory_names: muted_memory_names,
195
230
  parent_id: parent_id,
196
231
  scratch: scratch,
197
- delegate: delegate
232
+ delegate: delegate,
233
+ llm_context: llm_context
198
234
  )
199
235
  end
200
236
 
@@ -246,10 +282,12 @@ module Samagotchi
246
282
  "project_root" => nil,
247
283
  "preloaded_memory_names" => [],
248
284
  "muted_memory_names" => [],
285
+ "prompt_notes" => [],
249
286
  "parent_id" => nil,
250
287
  "scratch" => false,
251
288
  "last_turn" => nil,
252
- "delegate" => false
289
+ "delegate" => false,
290
+ "llm_context" => nil
253
291
  }.freeze
254
292
 
255
293
  # A session from a parsed session file. +messages+ false leaves the
@@ -368,6 +406,8 @@ module Samagotchi
368
406
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
369
407
  when "test_run" then !!@test_run
370
408
  when "delegate" then @delegate
409
+ when "llm_context" then llm_context_file
410
+ when "prompt_notes" then @prompt_notes.map(&:to_file)
371
411
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
372
412
  Array(instance_variable_get(:"@#{key}"))
373
413
  else instance_variable_get(:"@#{key}")
@@ -3,16 +3,18 @@
3
3
  require_relative "config"
4
4
  require_relative "model_profile"
5
5
  require_relative "served_model"
6
+ require_relative "prompt_note"
6
7
  require_relative "turn_flow"
7
8
  require_relative "tools/execute"
8
9
  require_relative "tools/builtins"
9
10
  require_relative "tools/context_read"
10
11
  require_relative "tools/peers"
11
12
  require_relative "commands/registry"
13
+ require_relative "llm_context_command"
12
14
 
13
15
  module Samagotchi
14
16
  # 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
17
+ # /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
16
18
  # the Engine and its TurnFlow; the host prints the result's output and
17
19
  # runs a continue turn when asked to (#run never runs a turn).
18
20
  #
@@ -26,6 +28,7 @@ module Samagotchi
26
28
  GUARDRAILS_COMMAND = "/guardrails"
27
29
  CONTEXT_COMMAND = "/context"
28
30
  HELP_COMMAND = "/help"
31
+ LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
29
32
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
30
33
  # shows this many per host; /models <text> lists every match.
31
34
  MODELS_PER_HOST = 20
@@ -73,6 +76,9 @@ module Samagotchi
73
76
  end
74
77
  registry.register(MODEL_COMMAND, "show or switch the model",
75
78
  match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
79
+ # Not anytime: it saves the session; it takes effect at the next turn's start.
80
+ registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
81
+ id: :llm_context, match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
76
82
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
77
83
  registry.register("/stats", "show the session's stats", local: true)
78
84
  registry.register("/recap", "show the session's recap", local: true)
@@ -105,6 +111,14 @@ module Samagotchi
105
111
  @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
106
112
  end
107
113
 
114
+ # The name of this chi's own session command +line+ is (one a worker
115
+ # runs), or nil. A worker that answers such a line unknown_command runs
116
+ # an older chi that doesn't have it yet (/llm-context against 0.39.0):
117
+ # the UIs say so (BridgeClient.stale_worker_message) instead of "not a
118
+ # session command".
119
+ # @return [String, nil]
120
+ def self.builtin_name(line) = builtin_registry.lookup(line)&.name
121
+
108
122
  # @return [String] the model /model clear goes back to
109
123
  attr_reader :default_model
110
124
 
@@ -330,6 +344,21 @@ module Samagotchi
330
344
  @save.call(session)
331
345
  end
332
346
 
347
+ # /llm-context (LLMContextCommand); :llm_context in changed when it set
348
+ # the session's values.
349
+ def llm_context(text)
350
+ output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_llm_context(session) })
351
+ .run(text.delete_prefix(LLM_CONTEXT_COMMAND))
352
+ reply(output, changed: changed ? [:llm_context] : [])
353
+ rescue ArgumentError => e
354
+ reply(e.message, status: :error)
355
+ end
356
+
357
+ def save_llm_context(session)
358
+ @engine.store_model!(session)
359
+ @save.call(session)
360
+ end
361
+
333
362
  def model(text)
334
363
  output, switched = model_command(text)
335
364
  reply(output, changed: switched ? [:model] : [])
@@ -342,9 +371,9 @@ module Samagotchi
342
371
  def model_command(input)
343
372
  suffix = input.delete_prefix(MODEL_COMMAND).strip
344
373
  if suffix.empty?
345
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @engine.model_ref_for(@default_model)
374
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
346
375
 
347
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
376
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false]
348
377
  end
349
378
 
350
379
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -414,14 +443,14 @@ module Samagotchi
414
443
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
415
444
 
416
445
  switch_model(@default_model)
417
- return ["runtime model reset to #{model_name}#{model_note}", true]
446
+ return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
418
447
  end
419
448
 
420
449
  switch_model(arg, persist_default: persist_default)
421
450
  if persist_default
422
- ["runtime model set to #{model_name}#{model_note} and default updated", true]
451
+ ["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
423
452
  else
424
- ["runtime model set to #{model_name}#{model_note}", true]
453
+ ["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
425
454
  end
426
455
  end
427
456
  end
@@ -462,6 +491,16 @@ module Samagotchi
462
491
  ""
463
492
  end
464
493
 
494
+ # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
495
+ # the session's prompt carries (after a switch, the ones the new
496
+ # model's prompt loads); "" without any.
497
+ def notes_note
498
+ text = PromptNote.text(@engine.prompt_notes)
499
+ text.empty? ? "" : "; notes: #{text}"
500
+ rescue StandardError
501
+ ""
502
+ end
503
+
465
504
  # "; thinking: off (models: qwen)" when the model has a level set.
466
505
  def thinking_note
467
506
  summary = @engine.thinking_summary
@@ -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
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  # The most turns (with their tool calls) a live snapshot carries: the
38
38
  # unsaved ones of a collector whose saves keep failing stay bounded.
39
39
  LIVE_RECORDS_CAP = 20
40
+ # The most characters of a failed turn's error message its record keeps.
41
+ FAILURE_MESSAGE_CHARS = 2000
40
42
 
41
43
  # A generation shorter than this gets no decode speed of its own: one
42
44
  # tiny generation would skew the session's average.
@@ -53,8 +55,14 @@ module Samagotchi
53
55
  # had none) and the session's tokens block as the snapshot has it.
54
56
  GenerationReport = Data.define(:speed, :tokens)
55
57
 
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)
58
+ # memory_index: the memory indexes the session's prompt held
59
+ # ({ system: { tokens:, lines: }, project: … }, string keys as saved),
60
+ # nil when an older file has none.
61
+ SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
62
+ :memory_index) do
63
+ def initialize(memory_index: nil, **) = super
64
+
65
+ def tokens = to_h.except(:ctx_pct, :memory_index)
58
66
  end
59
67
 
60
68
  # Per-turn transient state, reset on each turn_started.
@@ -150,7 +158,8 @@ module Samagotchi
150
158
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
151
159
  SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
152
160
  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"))
161
+ reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
162
+ memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
154
163
  rescue JSON::ParserError, SystemCallError, TypeError
155
164
  nil
156
165
  end
@@ -196,6 +205,7 @@ module Samagotchi
196
205
  @wall_clock = wall_clock || -> { Time.now }
197
206
  @session_id = nil
198
207
  @state_dir = nil
208
+ @memory_index = nil
199
209
  @started_at = nil
200
210
  @last_activity_at = nil
201
211
  @turn = nil
@@ -217,6 +227,14 @@ module Samagotchi
217
227
  end
218
228
  end
219
229
 
230
+ # The memory indexes the session's prompt holds (SystemPrompt#memory_index:
231
+ # { system: { tokens:, lines: }, project: … }), set by the Engine when
232
+ # it builds the prompt it sends; the snapshot and analytics.json carry it.
233
+ # @param block [Hash, nil]
234
+ def memory_index=(block)
235
+ @mutex.synchronize { @memory_index = block if block }
236
+ end
237
+
220
238
  # The sessions dir analytics.json is read from and saved to (nil: the
221
239
  # XDG default). Set it before #session_id=.
222
240
  attr_writer :state_dir
@@ -292,7 +310,7 @@ module Samagotchi
292
310
  when :turn_canceled
293
311
  end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
294
312
  when :turn_failed
295
- end_turn(status: "failed")
313
+ end_turn(status: "failed", failure: failure_fields(event))
296
314
  end
297
315
 
298
316
  @mutex.synchronize { @last_activity_at = now.iso8601(3) }
@@ -367,6 +385,7 @@ module Samagotchi
367
385
  cancellations: totals[:cancellations],
368
386
  tokens: tokens_block(totals, turn),
369
387
  context: context_block(@turn_records),
388
+ memory_index: @memory_index,
370
389
  profile: @profile,
371
390
  profile_source: @profile_source,
372
391
  served_model: @served_model,
@@ -480,9 +499,10 @@ module Samagotchi
480
499
  end
481
500
 
482
501
  # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
483
- # and who stopped it (+by+: a hook's bundle), so a reloaded web history
484
- # can show the cancel line the live one did.
485
- def end_turn(status: "completed", reason: nil, by: nil)
502
+ # and who stopped it (+by+: a hook's bundle), a failed one what its line
503
+ # says (+failure+, #failure_fields), so a reloaded web history can show
504
+ # the cancel or failure line the live one did.
505
+ def end_turn(status: "completed", reason: nil, by: nil, failure: nil)
486
506
  @mutex.synchronize do
487
507
  if @turn
488
508
  # A turn that failed mid-stream never saw its generation end:
@@ -505,6 +525,7 @@ module Samagotchi
505
525
  }
506
526
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
507
527
  record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
528
+ record[:failure] = failure if failure
508
529
  record.merge!(turn_token_fields(@turn))
509
530
  @turn_records << record
510
531
  count_turn(record)
@@ -513,6 +534,19 @@ module Samagotchi
513
534
  end
514
535
  end
515
536
 
537
+ # What turn_failed's line is made of (format.js failedTurnText): a
538
+ # provider error's summary, else the message (capped: a server's error
539
+ # page can be long) and class, and the steps that stayed (kept_steps).
540
+ def failure_fields(event)
541
+ summary = event[:summary].to_s
542
+ return { summary: summary, kept_steps: event[:kept_steps] }.compact unless summary.empty?
543
+
544
+ message = event[:message].to_s
545
+ message = "#{message[0, FAILURE_MESSAGE_CHARS - 1]}…" if message.length > FAILURE_MESSAGE_CHARS
546
+ { message: message.empty? ? nil : message, error_class: event[:error_class]&.to_s,
547
+ kept_steps: event[:kept_steps] }.compact
548
+ end
549
+
516
550
  # The model the server says answered, and the name asked for then (a
517
551
  # /model switch makes it stale until the next generation reports).
518
552
  def record_served_model(event)
@@ -875,6 +909,8 @@ module Samagotchi
875
909
  @served_model = prior["served_model"]
876
910
  @served_model_for = prior["served_model_for"]
877
911
  end
912
+ # The indexes the prompt last held, until this process builds its own.
913
+ @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
878
914
  # The window last seen, until this process's first generation reports.
879
915
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
880
916
  if window["window_tokens"] && @context_window_tokens.nil?
@@ -885,6 +921,10 @@ module Samagotchi
885
921
  nil
886
922
  end
887
923
 
924
+ def symbolized(hash)
925
+ hash.to_h { |key, value| [key.to_sym, value.is_a?(Hash) ? symbolized(value) : value] }
926
+ end
927
+
888
928
  def loaded_records(records)
889
929
  Array(records).filter_map { |record| record.transform_keys(&:to_sym) if record.is_a?(Hash) && record["id"] }
890
930
  end