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,14 +7,16 @@ require_relative "memory_paths"
7
7
  require_relative "tool_declarations"
8
8
  require_relative "tools/memory"
9
9
  require_relative "muted_memories"
10
+ require_relative "model_notes"
10
11
  require_relative "bundle_needs"
12
+ require_relative "memory_bundle/index_size"
11
13
  require_relative "thinking"
12
14
 
13
15
  module Samagotchi
14
16
  # The system prompt an Engine gives its model: the base prompt (tool
15
17
  # declarations and call syntax for the raw-prompt loop, none for the chat
16
18
  # loop, the shared guidance) and around it the thinking token, rg
17
- # guidance, the identity memory, the preloaded memories, AGENT.md, the
19
+ # guidance, the identity memory, the model notes, the preloaded memories, AGENT.md, the
18
20
  # memory indexes and, last, the model, where the session runs and the
19
21
  # session (#system_prompt_with_index has the order and why).
20
22
  #
@@ -45,11 +47,15 @@ module Samagotchi
45
47
  # @param session [#call] → Session, nil
46
48
  # @param thinking [#call] → Symbol, the effective model's level (Thinking)
47
49
  # @param model [#call] → ModelIdentity, nil (the section is left out)
50
+ # @param model_notes [#call] → Array<ModelNotes::Note>, the effective
51
+ # model's (none: the section is left out)
48
52
  # @param memories [Array<String>] the --memory list
49
53
  # @param muted_memory_names [Array<String>] normalized (MutedMemories)
50
- def initialize(profile:, tools:, session:, thinking:, model: -> {}, memories: [], muted_memory_names: [])
54
+ def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
55
+ muted_memory_names: [])
51
56
  @profile_lookup = profile
52
57
  @model_lookup = model
58
+ @model_notes_lookup = model_notes
53
59
  @tools_lookup = tools
54
60
  @session_lookup = session
55
61
  @thinking_lookup = thinking
@@ -71,16 +77,44 @@ module Samagotchi
71
77
  # @param layers [Array<Symbol>] the turn's LLM context layers: the
72
78
  # native prompt declares the tools they add (Tools::Registry#entries)
73
79
  def build(chat: false, thinking: nil, layers: [])
80
+ key = [chat, thinking, layers]
74
81
  @built ||= {}
75
- @built[[chat, thinking, layers]] ||= system_prompt_with_index(
76
- assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
77
- )
82
+ @index_sizes ||= {}
83
+ @notes_loaded ||= {}
84
+ unless @built.key?(key)
85
+ @section_notes = nil
86
+ @built[key] = system_prompt_with_index(
87
+ assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
88
+ )
89
+ @index_sizes[key] = @measured_index
90
+ @notes_loaded[key] = @section_notes || []
91
+ end
92
+ @memory_index = @index_sizes[key]
93
+ @prompt_notes = @notes_loaded[key]
94
+ @built[key]
78
95
  end
79
96
 
97
+ # The memory indexes the prompt #build returned last holds, per scope
98
+ # ({ system: { tokens:, lines: }, project: { … } }; MemoryBundle::IndexSize
99
+ # of the text after the session's mutes and the model notes' lines), nil
100
+ # before a build. Measured when the prompt is built: a memory write
101
+ # during the session changes neither the prompt nor this.
102
+ # @return [Hash, nil]
103
+ attr_reader :memory_index
104
+
105
+ # The model notes the prompt #build returned last carries
106
+ # (Array<ModelNotes::Note>, [] with none), nil before a build and after
107
+ # #reset!. Read when the prompt is built, as the section is.
108
+ # @return [Array<ModelNotes::Note>, nil]
109
+ attr_reader :prompt_notes
110
+
80
111
  # Drops the built prompts: the next #build reads the profile, tools,
81
112
  # indexes and memories again (a model or profile switch, changed tools).
82
113
  def reset!
83
114
  @built = nil
115
+ @index_sizes = nil
116
+ @notes_loaded = nil
117
+ @prompt_notes = nil
84
118
  @stable_lengths = nil
85
119
  end
86
120
 
@@ -254,11 +288,13 @@ module Samagotchi
254
288
  # still follow them).
255
289
  def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
256
290
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
291
+ indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
292
+ @measured_index = measure_indexes(indexes)
257
293
  memory_sections = [
258
- "Project memories:\n#{read_memory_index("project")}",
259
- "System memories:\n#{read_memory_index("system")}"
294
+ "Project memories:\n#{indexes[:project]}",
295
+ "System memories:\n#{indexes[:system]}"
260
296
  ].join("\n\n")
261
- stable = [thinking_token + base, rg_guidance, system_identity_section, explicit_memory_section,
297
+ stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
262
298
  project_specific_description, memory_sections]
263
299
  stable_text = stable.compact.join("\n")
264
300
  volatile = [current_model, project_location, current_session].compact
@@ -295,6 +331,23 @@ module Samagotchi
295
331
  nil
296
332
  end
297
333
 
334
+ # The model notes for the session's model (ModelNotes), right after
335
+ # identity: a heading per scope, each note under its name, the models:
336
+ # line left out. nil without any, so the prompt stays as it was.
337
+ def model_notes_section
338
+ @section_notes = []
339
+ notes = Array(@model_notes_lookup.call)
340
+ return nil if notes.empty?
341
+
342
+ ref = @model_lookup.call&.ref
343
+ notes.group_by(&:scope).map do |scope, list|
344
+ heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
345
+ [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
346
+ end.join("\n\n").tap { @section_notes = notes }
347
+ rescue StandardError
348
+ nil
349
+ end
350
+
298
351
  # ── Memory helpers ─────────────────────────────────────────────────────────
299
352
 
300
353
  # The session model's overlay keys, so the prompt's own memory bodies
@@ -308,9 +361,23 @@ module Samagotchi
308
361
  {}
309
362
  end
310
363
 
311
- # The scope's index text without the muted memories' lines.
364
+ # { system: { tokens:, lines: }, project: { … } } of the index texts
365
+ # the prompt holds; nil when they can't be measured.
366
+ def measure_indexes(indexes)
367
+ chars_per_token = MemoryBundle::IndexSize.chars_per_token
368
+ %i[system project].to_h do |scope|
369
+ [scope, MemoryBundle::IndexSize.of_text(scope, indexes[scope], chars_per_token: chars_per_token).figures]
370
+ end
371
+ rescue StandardError
372
+ nil
373
+ end
374
+
375
+ # The scope's index text without the muted memories' lines, nor the
376
+ # model notes' (their bodies are in their own section, or not for this
377
+ # model).
312
378
  def read_memory_index(scope)
313
- BundleNeeds.annotate_index(MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names), scope)
379
+ index = MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names)
380
+ BundleNeeds.annotate_index(ModelNotes.filter_index(index, scope), scope)
314
381
  end
315
382
 
316
383
  # Merge the config.yml `memories:` baseline with the explicit `--memory`
@@ -473,7 +540,8 @@ module Samagotchi
473
540
  "Model: this session runs on #{model.ref}#{where}.\n" \
474
541
  "Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
475
542
  "its base model's name, but this session runs what is named here; it changes only with /model. " \
476
- "Guidance for this model only goes in memory overlays: memory_write current_model_only: true."
543
+ "Guidance for this model only goes in a model note: a memory named model_notes_<name> whose first line is " \
544
+ "`models: <glob on this model's id or key>`."
477
545
  rescue StandardError
478
546
  nil
479
547
  end
@@ -36,14 +36,21 @@ module Samagotchi
36
36
  # @param memories [Array<String>] --memory: a new session's worker
37
37
  # preloads them (an existing session keeps its own list)
38
38
  # @param muted_memories [Array<String>] --mute: hidden from a new session
39
+ # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
40
+ # (LLMContextOverride.update): a new session starts with them; a
41
+ # resumed or attached one's worker gets a /llm-context, after the
42
+ # /model
39
43
  # @return [Symbol] :detached, :closed when the worker went away,
40
44
  # :failed when the --model switch didn't go through, or (input from a
41
45
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
42
46
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
43
- memories: [], muted_memories: [])
47
+ memories: [], muted_memories: [], llm_context: {})
44
48
  client = connect(attach: attach, shared: shared, resume: resume, model: model,
45
- memories: memories, muted_memories: muted_memories)
46
- first_command = model && (attach || resume) ? "/model #{model}" : nil
49
+ memories: memories, muted_memories: muted_memories, llm_context: llm_context)
50
+ commands = []
51
+ commands << "/model #{model}" if model && (attach || resume)
52
+ commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
53
+ first_command = commands.size > 1 ? commands : commands.first
47
54
  surface = open_surface
48
55
  attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
49
56
  first_command: first_command, no_interrupt: no_interrupt,
@@ -104,7 +111,7 @@ module Samagotchi
104
111
  # @return [BridgeClient] a client for the live Bridge of the session
105
112
  # @raise [Error]
106
113
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
107
- memories: [], muted_memories: [], err: $stderr)
114
+ memories: [], muted_memories: [], llm_context: {}, err: $stderr)
108
115
  sd = state_dir || Session.default_state_dir
109
116
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
110
117
  if attach
@@ -119,7 +126,8 @@ module Samagotchi
119
126
  SessionManager.resume_session(resume, state_dir: sd)
120
127
  else
121
128
  SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
122
- memories: memories, muted_memories: muted_memories)
129
+ memories: memories, muted_memories: muted_memories,
130
+ llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context))
123
131
  end
124
132
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
125
133
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -82,8 +82,9 @@ module Samagotchi
82
82
  # PlainSurface when the terminal can't show a live region)
83
83
  # @param client_id [String] this UI's id in the events ("tui:<pid>")
84
84
  # @param first_prompt [String, nil] sent once joined (`chi -p`)
85
- # @param first_command [String, nil] run before the first prompt
86
- # (`--model` on a resumed session: "/model X"); if it doesn't go
85
+ # @param first_command [String, Array<String>, nil] run before the
86
+ # first prompt, in order (`--model` on a resumed session: "/model X";
87
+ # --llm-context: "/llm-context strategy …"); if one doesn't go
87
88
  # through, the launch stops
88
89
  # @param no_interrupt [Boolean] post every turn with no_interrupt
89
90
  # @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
@@ -127,7 +128,7 @@ module Samagotchi
127
128
  @answered_ids = Set.new
128
129
  @reader = nil
129
130
  @first_prompt = first_prompt
130
- @first_command = first_command
131
+ @first_commands = Array(first_command)
131
132
  @first_command_id = nil
132
133
  @no_interrupt = no_interrupt
133
134
  @no_default_input = !default_input
@@ -521,6 +522,9 @@ module Samagotchi
521
522
  return
522
523
  end
523
524
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
525
+
526
+ name = older_worker_command(reply, line)
527
+ return @screen.commit(stale_worker("run #{name}")) if name
524
528
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
525
529
 
526
530
  detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
@@ -827,6 +831,14 @@ module Samagotchi
827
831
  # so it shows above what it got.
828
832
  def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
829
833
 
834
+ # The name of this chi's own command +line+ when the worker answered it
835
+ # unknown_command (it runs an older chi without it), else nil.
836
+ def older_worker_command(reply, line)
837
+ return nil unless reply.status == 400 && reply.json&.fetch("error", nil) == "unknown_command"
838
+
839
+ SessionCommands.builtin_name(line)
840
+ end
841
+
830
842
  def stale_worker(cant)
831
843
  BridgeClient.stale_worker_message(@client.session_id, cant: cant)
832
844
  end
@@ -980,18 +992,19 @@ module Samagotchi
980
992
  @shown_enqueued << entry[:enqueued_id]
981
993
  @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
982
994
  end
983
- return send_first_command if @first_command
995
+ return send_first_command unless @first_commands.empty?
984
996
 
985
997
  send_first_prompt
986
998
  nil
987
999
  end
988
1000
 
989
- # --model on a resumed session: switch its worker before the first
990
- # prompt goes; its :command_ran decides (#first_command_ran).
1001
+ # --model (or --llm-context) on a resumed session: run it in its
1002
+ # worker before the first prompt goes; its :command_ran decides
1003
+ # (#first_command_ran), and sends the next one.
991
1004
  # @return [Symbol, nil] :failed when the worker didn't take it
992
1005
  def send_first_command
993
- line = @first_command
994
- @first_command = nil
1006
+ line = @first_commands.shift
1007
+ @first_command_line = line
995
1008
  reply = @client.post_command(line: line, client_id: @client_id)
996
1009
  if reply.status == 202
997
1010
  @first_command_id = reply.json&.fetch("command_id", nil)
@@ -1000,28 +1013,35 @@ module Samagotchi
1000
1013
 
1001
1014
  why =
1002
1015
  if reply.status == 404 then stale_worker("run commands")
1016
+ elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
1003
1017
  elsif too_late?(reply) then LATE
1004
1018
  else "the worker answered #{reply.status}"
1005
1019
  end
1006
- @screen.commit("could not switch to the --model: #{why}")
1020
+ @screen.commit("#{first_command_failed}: #{why}")
1007
1021
  :failed
1008
1022
  rescue SystemCallError, IOError => e
1009
- @screen.commit("could not switch to the --model: #{worker_down(e)}")
1023
+ @screen.commit("#{first_command_failed}: #{worker_down(e)}")
1010
1024
  :failed
1011
1025
  end
1012
1026
 
1013
- # @return [Symbol, nil] :failed unless the switch went through
1027
+ # @return [Symbol, nil] :failed unless the command went through
1014
1028
  def first_command_ran(event)
1015
1029
  @first_command_id = nil
1016
1030
  if event[:status] == "ok"
1031
+ return send_first_command unless @first_commands.empty?
1032
+
1017
1033
  send_first_prompt
1018
1034
  return nil
1019
1035
  end
1020
1036
 
1021
- @screen.commit("could not switch to the --model: #{event[:output]}")
1037
+ @screen.commit("#{first_command_failed}: #{event[:output]}")
1022
1038
  :failed
1023
1039
  end
1024
1040
 
1041
+ def first_command_failed
1042
+ @first_command_line.to_s.start_with?("/model") ? "could not switch to the --model" : "could not set the --llm-context"
1043
+ end
1044
+
1025
1045
  # After the join, so it lands below what the session already had. Our
1026
1046
  # own prompts are never echoed from events (a typed one stays on screen),
1027
1047
  # so it is shown here as if typed.
@@ -94,6 +94,8 @@ module Samagotchi
94
94
  # that couldn't be used).
95
95
  @view.finish_thinking_spinner
96
96
  @view.print_line(@view.turn_failed_line(event[:summary] || event[:message], event[:duration_ms]))
97
+ # Its work so far stayed (the Engine's kept_steps), as after a cancel.
98
+ @view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) if event[:kept_steps] && !@turn_continues
97
99
  when :guardrail_warning
98
100
  @view.print_line(self.class.load_warning_line(event))
99
101
  when :hook_notice
@@ -5,6 +5,8 @@ require_relative "../served_model"
5
5
  require_relative "../image_store"
6
6
  require_relative "../turn_note"
7
7
  require_relative "../steer"
8
+ require_relative "../memory_bundle/index_size"
9
+ require_relative "../prompt_note"
8
10
 
9
11
  module Samagotchi
10
12
  class TerminalUI
@@ -314,6 +316,25 @@ module Samagotchi
314
316
  "#{"~" if source.to_s == "estimate"}#{number} tok/s"
315
317
  end
316
318
 
319
+ # "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
320
+ # the memory indexes the session's prompt holds (the snapshot's
321
+ # memory_index, symbol or string keys); "" without one. As ctx.js
322
+ # memoryIndexText (spec/shared/labels_matrix.json).
323
+ def memory_index_text(block)
324
+ return "" unless block.is_a?(Hash)
325
+
326
+ scopes = %w[system project].filter_map do |scope|
327
+ figures = block[scope] || block[scope.to_sym]
328
+ tokens = figures.is_a?(Hash) ? (figures["tokens"] || figures[:tokens]) : nil
329
+ [scope, tokens.to_i] if tokens.is_a?(Numeric)
330
+ end
331
+ return "" if scopes.empty?
332
+
333
+ count = ->(tokens) { MemoryBundle::IndexSize.count_text(tokens) }
334
+ "~#{count.call(scopes.sum(&:last))} tokens in this session's prompt " \
335
+ "(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
336
+ end
337
+
317
338
  # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
318
339
  # none. As ctx.js costText.
319
340
  def cost_text(cost)
@@ -463,6 +484,13 @@ module Samagotchi
463
484
  end
464
485
  lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
465
486
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
487
+ memory_index = memory_index_text(snapshot[:memory_index])
488
+ lines << "memory index: #{memory_index}" unless memory_index.empty?
489
+ # The model notes the session's prompt carried (Engine#prompt_notes).
490
+ notes = PromptNote.text(snapshot[:prompt_notes])
491
+ lines << "model notes: #{notes}" unless notes.empty?
492
+ llm_context = llm_context_stats_text(snapshot[:llm_context])
493
+ lines << "llm context: #{llm_context}" if llm_context
466
494
  if snapshot[:served_model]
467
495
  asked = snapshot[:served_model_for]
468
496
  note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
@@ -471,6 +499,18 @@ module Samagotchi
471
499
  lines.join("\n")
472
500
  end
473
501
 
502
+ # "stale,forget (the session); apply turn_end (models: x); budget
503
+ # 64000 (the session)" for /stats (Explained#summary; symbol or
504
+ # string keys, the attached TUI's come as JSON); nil without one.
505
+ def llm_context_stats_text(summary)
506
+ return nil unless summary.is_a?(Hash)
507
+
508
+ get = ->(key) { summary[key] || summary[key.to_s] }
509
+ budget = get.call(:budget_tokens) ? "#{get.call(:budget_tokens)} tokens" : "off"
510
+ "#{get.call(:strategy)} (#{get.call(:strategy_where)}); apply #{get.call(:apply)} (#{get.call(:apply_where)}); " \
511
+ "budget #{budget} (#{get.call(:budget_where)})"
512
+ end
513
+
474
514
  # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
475
515
  # 212" for the tokens line; the counts a server didn't report are left
476
516
  # out (re-prefilled: SessionMetrics' reprefill_sum).
@@ -85,10 +85,13 @@ module Samagotchi
85
85
 
86
86
  # @param scratch [Boolean] `chi scratch`: a new session that is deleted
87
87
  # however the REPL ends, saves no memories and writes no recap
88
+ # @param llm_context [Hash{Symbol => String}] --llm-context* as typed
89
+ # (LLMContextOverride.update): set on the new or resumed session
88
90
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
89
- spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
91
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
90
92
  @scratch = scratch
91
93
  @prompt = prompt
94
+ @llm_context_words = llm_context
92
95
  @default_model_name = ModelProfile.required_model_name(nil)
93
96
  # As typed: the Engine applies an alias where it resolves the model.
94
97
  flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
@@ -238,6 +241,12 @@ module Samagotchi
238
241
  muted_memory_names: @muted_memory_names,
239
242
  scratch: @scratch
240
243
  ))
244
+ # --llm-context*: the session's own values, over a resumed one's.
245
+ # Saved at once on a resumed one: leaving before a turn keeps them.
246
+ unless @llm_context_words.empty?
247
+ session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words)
248
+ session.save if @resume_session
249
+ end
241
250
  claim_session!(session.id) unless @owner_lock
242
251
  keep_cards(session)
243
252
  # Saved at once, so a process killed before its first turn leaves a
@@ -759,20 +768,21 @@ module Samagotchi
759
768
  rescue LLM::ProviderError, ImageStore::Error => e
760
769
  # Engine closed the turn (:turn_failed), which the renderer showed.
761
770
  # An @path image that can't be used fails the turn the same way.
762
- summary = e.respond_to?(:summary) ? e.summary : e.message
763
771
  # The model reads why on its next turn. An image that couldn't be
764
772
  # used, or that the host refused, never reached it: no note for that
765
773
  # (a first turn refused that way leaves the session empty, as before).
766
- note = TurnNote.failed(summary, restored: true) if e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
767
- @turn_flow.prompt_turn_failed(note: note)
774
+ note = e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
775
+ # A turn that got somewhere stays (the renderer said so, and that
776
+ # !rollback erases it), as the Engine saved it: no prompt goes back.
777
+ kept = @turn_flow.prompt_turn_failed(error: e, note: note) == :kept
768
778
  # The Engine saved the failed turn; the file follows the rollback.
769
- save_session(session) if note
779
+ save_session(session) if note || kept
770
780
  if piped_input?
771
781
  # Nobody can edit a restored prompt, and Reline would hand it back
772
782
  # as the next line: the turn would run again and again. It failed;
773
783
  # the exit status says so.
774
784
  @piped_turn_failed = true
775
- else
785
+ elsif !kept
776
786
  @surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
777
787
  end
778
788
  return
@@ -8,6 +8,7 @@ require_relative "log"
8
8
  require_relative "edit_preview"
9
9
  require_relative "tools/tool_path"
10
10
  require_relative "memory_bundle/index_sync"
11
+ require_relative "memory_bundle/index_size"
11
12
 
12
13
  module Samagotchi
13
14
  # The single per-call path both loops use: the tool_call_started and
@@ -71,9 +72,12 @@ module Samagotchi
71
72
  waited_ms = ((@clock.call - asked_at) * 1000).round
72
73
  end
73
74
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
75
+ index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
74
76
  result = verdict.deny? ? denied(call, verdict) : dispatch(call)
75
77
  diff = file_change(call, before)
76
- refresh_memory_index(call) if diff
78
+ if diff && refresh_memory_index(call)
79
+ result = with_index_note(result, index_before, call)
80
+ end
77
81
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
78
82
  result, images = attach_images(call, result) if result[:images]
79
83
 
@@ -154,6 +158,27 @@ module Samagotchi
154
158
  MemoryBundle::IndexSync.refresh(Tools::ToolPath.normalize(call[:path]))
155
159
  end
156
160
 
161
+ # The index size of the memory scope an edit/write's path is in
162
+ # (MemoryBundle::IndexSize), nil for any other path, or when
163
+ # memory.index_warn_tokens is off: other writes cost nothing.
164
+ def memory_index_size(call)
165
+ scope = MemoryBundle::IndexSync.memory_scope(Tools::ToolPath.normalize(call[:path]))
166
+ return nil unless scope && MemoryBundle::IndexSize.warn_limit.positive?
167
+
168
+ MemoryBundle::IndexSize.measure(scope)
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # +result+ with the note memory_write gives when the refreshed index
174
+ # line took the scope's index over memory.index_warn_tokens.
175
+ def with_index_note(result, before, call)
176
+ return result unless before
177
+
178
+ note = MemoryBundle::IndexSize.crossing_note(before, memory_index_size(call))
179
+ note ? result.merge(output: "#{result[:output]}\n\n#{note}") : result
180
+ end
181
+
157
182
  # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
158
183
  # binary file). They become "?" here, before the output reaches the
159
184
  # conversation, the events and the saved session: JSON.generate raises
@@ -5,11 +5,14 @@ require "digest"
5
5
  require "date"
6
6
  require_relative "../atomic_file"
7
7
  require_relative "../memory_paths"
8
+ require_relative "../model_match"
8
9
 
9
10
  module Samagotchi
10
11
  module Tools
11
12
  MEMORY_INDEX = "index"
12
13
  VALID_SCOPES = %w[project system].freeze
14
+ # Model notes (ModelNotes): `model_notes_<name>`, no dot in the name.
15
+ MODEL_NOTES_PREFIX = "model_notes_"
13
16
 
14
17
  # Reads a memory entry from scoped memory directories.
15
18
  # When scope is omitted, reads from project first and falls back to system.
@@ -111,8 +114,11 @@ module Samagotchi
111
114
  raise ArgumentError, "invalid scope '#{scope}', expected one of: #{VALID_SCOPES.join(", ")}"
112
115
  end
113
116
 
114
- def self.scoped_index(scope)
115
- dir = memories_dir(scope)
117
+ # +scope+'s index.md, or a listing of its files when it has none: what
118
+ # a session's prompt carries for the scope (before its filters).
119
+ # @param env [Hash] the XDG env the memories dir resolves from
120
+ def self.scoped_index(scope, env: ENV)
121
+ dir = memories_dir(scope, env: env)
116
122
  index_path = File.join(dir, "#{MEMORY_INDEX}.md")
117
123
  return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
118
124
 
@@ -170,6 +176,14 @@ module Samagotchi
170
176
 
171
177
  return remove(entry_name, scope)
172
178
  end
179
+ if entry_name.include?(",")
180
+ return "Error: invalid memory name '#{entry_name}': no commas (memory_read reads a comma list of names); " \
181
+ "use '#{entry_name.tr(",", "_")}'"
182
+ end
183
+ return dotted_model_note_error(entry_name) if dotted_model_note?(entry_name)
184
+ if (error = model_note_body_error(entry_name, body, current_model_only))
185
+ return error
186
+ end
173
187
 
174
188
  description = normalize_description(description)
175
189
  if description && description.length > DESCRIPTION_LIMIT
@@ -210,6 +224,9 @@ module Samagotchi
210
224
  end
211
225
 
212
226
  file_path = File.join(dir, "#{entry_name}.md")
227
+ # The index's size before the write, for the note on crossing
228
+ # memory.index_warn_tokens (not for the verbatim index write).
229
+ before = index_size(resolved_scope) unless entry_name == MEMORY_INDEX
213
230
  if entry_name == MEMORY_INDEX
214
231
  Samagotchi::MemoryBundle::IndexUpdater.locked_write(dir) { body }
215
232
  else
@@ -222,6 +239,7 @@ module Samagotchi
222
239
  # trigger upsert logic.
223
240
  if !(entry_name == MEMORY_INDEX) && manage_index(resolved_scope, entry_name, bytes, description)
224
241
  message += " Index line refreshed automatically."
242
+ message += index_crossing_note(before, resolved_scope)
225
243
  end
226
244
 
227
245
  message
@@ -229,6 +247,34 @@ module Samagotchi
229
247
  "Error: #{e.message}"
230
248
  end
231
249
 
250
+ # A model note's name with a dot would read as a model overlay
251
+ # (`<name>.<key>.md`) next to a note of the stem's name.
252
+ def self.dotted_model_note?(name) = name.start_with?(MODEL_NOTES_PREFIX) && name.include?(".")
253
+
254
+ def self.dotted_model_note_error(name)
255
+ "Error: invalid model note name '#{name}': no dot after #{MODEL_NOTES_PREFIX} (a dotted name reads as a " \
256
+ "model overlay); use '#{undotted(name)}'"
257
+ end
258
+
259
+ # +name+ with its dots made dashes, a trailing ".md" dropped first
260
+ # (`model_notes_x.md` → `model_notes_x`).
261
+ def self.undotted(name) = name.delete_suffix(".md").tr(".", "-")
262
+
263
+ # A model note's content must start with its models: line, or the note
264
+ # would never load (and its index line would hide it from no one). Its
265
+ # model overlay (current_model_only) has none.
266
+ def self.model_note_body_error(name, body, current_model_only)
267
+ return nil unless name.start_with?(MODEL_NOTES_PREFIX) && !body.empty? && !current_model_only
268
+ return nil if ModelMatch.models_line(body)
269
+
270
+ first = body.each_line.first.to_s.strip
271
+ first = "#{first[0, 60]}…" if first.length > 60
272
+ "Error: model note '#{name}' needs a first line saying which models it is for, `models: <glob>|small|…` " \
273
+ "(e.g. `models: deepseek-*|small`: globs on the model id or key, small per guardrails.small_models), " \
274
+ "then the note; the first line is #{first.empty? ? "empty" : "`#{first}`"}. A memory that isn't a model " \
275
+ "note needs a name without the #{MODEL_NOTES_PREFIX} prefix."
276
+ end
277
+
232
278
  # A description as the index line takes it: whitespace (newlines too)
233
279
  # collapsed to single spaces; nil when blank, which keeps the stored one.
234
280
  def self.normalize_description(description)
@@ -251,8 +297,29 @@ module Samagotchi
251
297
  file_path = File.join(MemoryRead.memories_dir(resolved_scope), "#{entry_name}.md")
252
298
  return "Error: no memory '#{entry_name}' in #{resolved_scope} scope; pass content to create it" unless File.file?(file_path)
253
299
 
300
+ # Measured around the call, whatever manage_index returns.
301
+ before = index_size(resolved_scope)
254
302
  manage_index(resolved_scope, entry_name, File.size(file_path), description)
255
- "Memory '#{entry_name}' description updated in #{resolved_scope} scope (file unchanged)."
303
+ "Memory '#{entry_name}' description updated in #{resolved_scope} scope (file unchanged)." \
304
+ "#{index_crossing_note(before, resolved_scope)}"
305
+ end
306
+
307
+ # +scope+'s index size (MemoryBundle::IndexSize), or nil when
308
+ # memory.index_warn_tokens is off or the index can't be read.
309
+ def self.index_size(scope)
310
+ require_relative "../memory_bundle/index_size"
311
+ size = Samagotchi::MemoryBundle::IndexSize
312
+ size.measure(scope) if size.warn_limit.positive?
313
+ end
314
+
315
+ # "\n\nNote: the <scope> memory index is now ~N tokens …" when the
316
+ # write took +scope+'s index from at or under memory.index_warn_tokens
317
+ # to over it (+before+: its size before the write), else "".
318
+ def self.index_crossing_note(before, scope)
319
+ return "" unless before
320
+
321
+ note = Samagotchi::MemoryBundle::IndexSize.crossing_note(before, index_size(scope))
322
+ note ? "\n\n#{note}" : ""
256
323
  end
257
324
 
258
325
  # remove: true. Moves <name>.md and its model overlays into one