samagotchi 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +25 -2
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +90 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bridge/card_store.rb +6 -7
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  17. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/engine.rb +91 -16
  20. data/lib/samagotchi/events.rb +8 -1
  21. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  22. data/lib/samagotchi/guardrails/rules.rb +27 -17
  23. data/lib/samagotchi/guardrails/targets.rb +40 -1
  24. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  25. data/lib/samagotchi/llm/errors.rb +33 -1
  26. data/lib/samagotchi/llm_context_command.rb +151 -0
  27. data/lib/samagotchi/llm_context_override.rb +180 -0
  28. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  29. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  31. data/lib/samagotchi/model_match.rb +54 -0
  32. data/lib/samagotchi/model_notes.rb +202 -0
  33. data/lib/samagotchi/parent_report.rb +12 -2
  34. data/lib/samagotchi/plugin/context.rb +3 -2
  35. data/lib/samagotchi/plugin/sessions.rb +3 -1
  36. data/lib/samagotchi/reply_wait.rb +3 -17
  37. data/lib/samagotchi/self_report.rb +15 -0
  38. data/lib/samagotchi/send_command.rb +22 -2
  39. data/lib/samagotchi/session.rb +29 -4
  40. data/lib/samagotchi/session_commands.rb +21 -1
  41. data/lib/samagotchi/session_manager.rb +5 -2
  42. data/lib/samagotchi/session_metrics.rb +26 -3
  43. data/lib/samagotchi/system_prompt.rb +66 -11
  44. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
  46. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  47. data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
  48. data/lib/samagotchi/terminal_ui.rb +16 -6
  49. data/lib/samagotchi/tool_runner.rb +26 -1
  50. data/lib/samagotchi/tools/memory.rb +70 -3
  51. data/lib/samagotchi/turn_flow.rb +36 -8
  52. data/lib/samagotchi/turn_note.rb +40 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +20 -1
  55. data/lib/samagotchi/web/public/app.js +40 -6
  56. data/lib/samagotchi/web/public/ctx.js +28 -5
  57. data/lib/samagotchi/web/public/format.js +5 -2
  58. data/lib/samagotchi/web/public/index.html +12 -0
  59. data/lib/samagotchi/web/public/live_turn.js +1 -0
  60. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  61. data/lib/samagotchi/web/public/session_state.js +3 -0
  62. data/lib/samagotchi/web/public/timing.js +5 -1
  63. data/lib/samagotchi/web/session_summary.rb +2 -0
  64. data/lib/samagotchi/worker.rb +24 -18
  65. metadata +7 -1
@@ -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,32 @@ 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
+ unless @built.key?(key)
84
+ @built[key] = system_prompt_with_index(
85
+ assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
86
+ )
87
+ @index_sizes[key] = @measured_index
88
+ end
89
+ @memory_index = @index_sizes[key]
90
+ @built[key]
78
91
  end
79
92
 
93
+ # The memory indexes the prompt #build returned last holds, per scope
94
+ # ({ system: { tokens:, lines: }, project: { … } }; MemoryBundle::IndexSize
95
+ # of the text after the session's mutes and the model notes' lines), nil
96
+ # before a build. Measured when the prompt is built: a memory write
97
+ # during the session changes neither the prompt nor this.
98
+ # @return [Hash, nil]
99
+ attr_reader :memory_index
100
+
80
101
  # Drops the built prompts: the next #build reads the profile, tools,
81
102
  # indexes and memories again (a model or profile switch, changed tools).
82
103
  def reset!
83
104
  @built = nil
105
+ @index_sizes = nil
84
106
  @stable_lengths = nil
85
107
  end
86
108
 
@@ -254,11 +276,13 @@ module Samagotchi
254
276
  # still follow them).
255
277
  def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
256
278
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
279
+ indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
280
+ @measured_index = measure_indexes(indexes)
257
281
  memory_sections = [
258
- "Project memories:\n#{read_memory_index("project")}",
259
- "System memories:\n#{read_memory_index("system")}"
282
+ "Project memories:\n#{indexes[:project]}",
283
+ "System memories:\n#{indexes[:system]}"
260
284
  ].join("\n\n")
261
- stable = [thinking_token + base, rg_guidance, system_identity_section, explicit_memory_section,
285
+ stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
262
286
  project_specific_description, memory_sections]
263
287
  stable_text = stable.compact.join("\n")
264
288
  volatile = [current_model, project_location, current_session].compact
@@ -295,6 +319,22 @@ module Samagotchi
295
319
  nil
296
320
  end
297
321
 
322
+ # The model notes for the session's model (ModelNotes), right after
323
+ # identity: a heading per scope, each note under its name, the models:
324
+ # line left out. nil without any, so the prompt stays as it was.
325
+ def model_notes_section
326
+ notes = Array(@model_notes_lookup.call)
327
+ return nil if notes.empty?
328
+
329
+ ref = @model_lookup.call&.ref
330
+ notes.group_by(&:scope).map do |scope, list|
331
+ heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
332
+ [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
333
+ end.join("\n\n")
334
+ rescue StandardError
335
+ nil
336
+ end
337
+
298
338
  # ── Memory helpers ─────────────────────────────────────────────────────────
299
339
 
300
340
  # The session model's overlay keys, so the prompt's own memory bodies
@@ -308,9 +348,23 @@ module Samagotchi
308
348
  {}
309
349
  end
310
350
 
311
- # The scope's index text without the muted memories' lines.
351
+ # { system: { tokens:, lines: }, project: { … } } of the index texts
352
+ # the prompt holds; nil when they can't be measured.
353
+ def measure_indexes(indexes)
354
+ chars_per_token = MemoryBundle::IndexSize.chars_per_token
355
+ %i[system project].to_h do |scope|
356
+ [scope, MemoryBundle::IndexSize.of_text(scope, indexes[scope], chars_per_token: chars_per_token).figures]
357
+ end
358
+ rescue StandardError
359
+ nil
360
+ end
361
+
362
+ # The scope's index text without the muted memories' lines, nor the
363
+ # model notes' (their bodies are in their own section, or not for this
364
+ # model).
312
365
  def read_memory_index(scope)
313
- BundleNeeds.annotate_index(MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names), scope)
366
+ index = MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names)
367
+ BundleNeeds.annotate_index(ModelNotes.filter_index(index, scope), scope)
314
368
  end
315
369
 
316
370
  # Merge the config.yml `memories:` baseline with the explicit `--memory`
@@ -473,7 +527,8 @@ module Samagotchi
473
527
  "Model: this session runs on #{model.ref}#{where}.\n" \
474
528
  "Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
475
529
  "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."
530
+ "Guidance for this model only goes in a model note: a memory named model_notes_<name> whose first line is " \
531
+ "`models: <glob on this model's id or key>`."
477
532
  rescue StandardError
478
533
  nil
479
534
  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
@@ -980,18 +981,19 @@ module Samagotchi
980
981
  @shown_enqueued << entry[:enqueued_id]
981
982
  @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
982
983
  end
983
- return send_first_command if @first_command
984
+ return send_first_command unless @first_commands.empty?
984
985
 
985
986
  send_first_prompt
986
987
  nil
987
988
  end
988
989
 
989
- # --model on a resumed session: switch its worker before the first
990
- # prompt goes; its :command_ran decides (#first_command_ran).
990
+ # --model (or --llm-context) on a resumed session: run it in its
991
+ # worker before the first prompt goes; its :command_ran decides
992
+ # (#first_command_ran), and sends the next one.
991
993
  # @return [Symbol, nil] :failed when the worker didn't take it
992
994
  def send_first_command
993
- line = @first_command
994
- @first_command = nil
995
+ line = @first_commands.shift
996
+ @first_command_line = line
995
997
  reply = @client.post_command(line: line, client_id: @client_id)
996
998
  if reply.status == 202
997
999
  @first_command_id = reply.json&.fetch("command_id", nil)
@@ -1003,25 +1005,31 @@ module Samagotchi
1003
1005
  elsif too_late?(reply) then LATE
1004
1006
  else "the worker answered #{reply.status}"
1005
1007
  end
1006
- @screen.commit("could not switch to the --model: #{why}")
1008
+ @screen.commit("#{first_command_failed}: #{why}")
1007
1009
  :failed
1008
1010
  rescue SystemCallError, IOError => e
1009
- @screen.commit("could not switch to the --model: #{worker_down(e)}")
1011
+ @screen.commit("#{first_command_failed}: #{worker_down(e)}")
1010
1012
  :failed
1011
1013
  end
1012
1014
 
1013
- # @return [Symbol, nil] :failed unless the switch went through
1015
+ # @return [Symbol, nil] :failed unless the command went through
1014
1016
  def first_command_ran(event)
1015
1017
  @first_command_id = nil
1016
1018
  if event[:status] == "ok"
1019
+ return send_first_command unless @first_commands.empty?
1020
+
1017
1021
  send_first_prompt
1018
1022
  return nil
1019
1023
  end
1020
1024
 
1021
- @screen.commit("could not switch to the --model: #{event[:output]}")
1025
+ @screen.commit("#{first_command_failed}: #{event[:output]}")
1022
1026
  :failed
1023
1027
  end
1024
1028
 
1029
+ def first_command_failed
1030
+ @first_command_line.to_s.start_with?("/model") ? "could not switch to the --model" : "could not set the --llm-context"
1031
+ end
1032
+
1025
1033
  # After the join, so it lands below what the session already had. Our
1026
1034
  # own prompts are never echoed from events (a typed one stays on screen),
1027
1035
  # 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,7 @@ 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"
8
9
 
9
10
  module Samagotchi
10
11
  class TerminalUI
@@ -314,6 +315,25 @@ module Samagotchi
314
315
  "#{"~" if source.to_s == "estimate"}#{number} tok/s"
315
316
  end
316
317
 
318
+ # "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
319
+ # the memory indexes the session's prompt holds (the snapshot's
320
+ # memory_index, symbol or string keys); "" without one. As ctx.js
321
+ # memoryIndexText (spec/shared/labels_matrix.json).
322
+ def memory_index_text(block)
323
+ return "" unless block.is_a?(Hash)
324
+
325
+ scopes = %w[system project].filter_map do |scope|
326
+ figures = block[scope] || block[scope.to_sym]
327
+ tokens = figures.is_a?(Hash) ? (figures["tokens"] || figures[:tokens]) : nil
328
+ [scope, tokens.to_i] if tokens.is_a?(Numeric)
329
+ end
330
+ return "" if scopes.empty?
331
+
332
+ count = ->(tokens) { MemoryBundle::IndexSize.count_text(tokens) }
333
+ "~#{count.call(scopes.sum(&:last))} tokens in this session's prompt " \
334
+ "(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
335
+ end
336
+
317
337
  # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
318
338
  # none. As ctx.js costText.
319
339
  def cost_text(cost)
@@ -463,6 +483,10 @@ module Samagotchi
463
483
  end
464
484
  lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
465
485
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
486
+ memory_index = memory_index_text(snapshot[:memory_index])
487
+ lines << "memory index: #{memory_index}" unless memory_index.empty?
488
+ llm_context = llm_context_stats_text(snapshot[:llm_context])
489
+ lines << "llm context: #{llm_context}" if llm_context
466
490
  if snapshot[:served_model]
467
491
  asked = snapshot[:served_model_for]
468
492
  note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
@@ -471,6 +495,18 @@ module Samagotchi
471
495
  lines.join("\n")
472
496
  end
473
497
 
498
+ # "stale,forget (the session); apply turn_end (models: x); budget
499
+ # 64000 (the session)" for /stats (Explained#summary; symbol or
500
+ # string keys, the attached TUI's come as JSON); nil without one.
501
+ def llm_context_stats_text(summary)
502
+ return nil unless summary.is_a?(Hash)
503
+
504
+ get = ->(key) { summary[key] || summary[key.to_s] }
505
+ budget = get.call(:budget_tokens) ? "#{get.call(:budget_tokens)} tokens" : "off"
506
+ "#{get.call(:strategy)} (#{get.call(:strategy_where)}); apply #{get.call(:apply)} (#{get.call(:apply_where)}); " \
507
+ "budget #{budget} (#{get.call(:budget_where)})"
508
+ end
509
+
474
510
  # ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
475
511
  # 212" for the tokens line; the counts a server didn't report are left
476
512
  # 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
@@ -3,6 +3,7 @@
3
3
  require_relative "output_formatter"
4
4
  require_relative "turn_note"
5
5
  require_relative "context_note"
6
+ require_relative "llm/errors"
6
7
 
7
8
  module Samagotchi
8
9
  # The state around turns that the REPL and a session worker share, kept out
@@ -95,17 +96,44 @@ module Samagotchi
95
96
  end
96
97
  end
97
98
 
98
- # A prompt turn failed: back to the conversation before it, plus the
99
- # note that says so (TurnNote.failed) in place of an older one at the
100
- # tail, so the model reads why its last message went unanswered.
101
- def prompt_turn_failed(note: nil)
99
+ # A prompt turn (or a wake turn) failed. One that got somewhere (the
100
+ # Engine kept its tool results: LLM::FailedTurn.kept_steps, never for
101
+ # a context overflow) stays, as after a cancel: its tool calls ran, and
102
+ # the model must see what they did. The checkpoint stays for !rollback, and
103
+ # the prompt is answered by what the turn did, so it doesn't go back to
104
+ # its sender. One that got nowhere goes back to the conversation before
105
+ # it, and its prompt to its sender (the caller hands it back).
106
+ # Either way the note at the tail says why (TurnNote.failed), in place
107
+ # of an older one, so the model reads why its last message went
108
+ # unanswered.
109
+ # @param error [Exception, nil] what failed: its one line is the note's
110
+ # @param wake [String, nil] what woke the turn (TurnNote.failed's wake:)
111
+ # @param wake_kept [String, nil] what becomes of the wake's news when
112
+ # the turn is rolled back (TurnNote.failed's kept:)
113
+ # @param note [Boolean] false: no note (an image that never reached the
114
+ # model)
115
+ # @return [Symbol] :kept or :restored
116
+ def prompt_turn_failed(error: nil, wake: nil, wake_kept: nil, note: true)
117
+ summary = error && (error.respond_to?(:summary) ? error.summary : error.message)
118
+ steps = LLM::FailedTurn.kept_steps(error)
119
+ @offer = nil
120
+ if steps
121
+ # The Engine's note says so already; a wake turn's says what woke it.
122
+ if wake && note
123
+ @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint,
124
+ TurnNote.failed(summary, wake: wake, steps: steps)))
125
+ end
126
+ return :kept
127
+ end
128
+
129
+ failed = note && summary && TurnNote.failed(summary, restored: wake.nil?, wake: wake, kept: wake_kept)
102
130
  if @checkpoint
103
- restore(@checkpoint, note: note)
104
- elsif note
105
- @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, note))
131
+ restore(@checkpoint, note: failed)
132
+ elsif failed
133
+ @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, failed))
106
134
  end
107
- @offer = nil
108
135
  @checkpoint = nil
136
+ :restored
109
137
  end
110
138
 
111
139
  # Answer the offer with no (Stop): the interrupted turn stays, as