samagotchi 0.41.0 → 0.43.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/cli.md +8 -6
  4. data/docs/configuration.md +8 -0
  5. data/docs/context.md +46 -0
  6. data/docs/memory.md +18 -0
  7. data/docs/plugins.md +27 -5
  8. data/docs/sessions.md +5 -5
  9. data/docs/testing.md +16 -0
  10. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  11. data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +8 -4
  14. data/lib/samagotchi/context_command.rb +5 -2
  15. data/lib/samagotchi/context_sources.rb +40 -0
  16. data/lib/samagotchi/engine.rb +31 -1
  17. data/lib/samagotchi/model_notes.rb +7 -4
  18. data/lib/samagotchi/plugin/attached_context.rb +75 -11
  19. data/lib/samagotchi/prompt_note.rb +53 -0
  20. data/lib/samagotchi/self_report.rb +32 -0
  21. data/lib/samagotchi/session.rb +16 -1
  22. data/lib/samagotchi/session_commands.rb +24 -5
  23. data/lib/samagotchi/session_metrics.rb +21 -4
  24. data/lib/samagotchi/system_prompt.rb +14 -1
  25. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -0
  26. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  27. data/lib/samagotchi/tool_declarations.rb +2 -1
  28. data/lib/samagotchi/turn_flow.rb +8 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +55 -8
  31. data/lib/samagotchi/web/public/app.js +73 -11
  32. data/lib/samagotchi/web/public/ctx.js +27 -2
  33. data/lib/samagotchi/web/public/data.js +6 -3
  34. data/lib/samagotchi/web/public/format.js +24 -0
  35. data/lib/samagotchi/web/public/index.html +8 -0
  36. data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
  37. data/lib/samagotchi/web/public/model_pick.js +3 -1
  38. data/lib/samagotchi/web/public/model_picker.js +2 -0
  39. data/lib/samagotchi/web/public/session_state.js +5 -0
  40. data/lib/samagotchi/web/public/timing.js +3 -1
  41. data/lib/samagotchi/web/public/turn_view.js +15 -2
  42. data/lib/samagotchi/web/session_summary.rb +2 -0
  43. data/lib/samagotchi/worker.rb +16 -4
  44. data/lib/samagotchi/worker_wakes.rb +2 -1
  45. metadata +2 -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"
@@ -59,6 +61,7 @@ module Samagotchi
59
61
  ["log", log_path(env)],
60
62
  ["model", model ? model_label(model, default, env) : "(not configured)"],
61
63
  ["model key", model ? model_key_for(model, env) : "-"],
64
+ ["model notes", model ? model_notes_for(model, env) : "-"],
62
65
  ["host", model ? host_for(model, env) : "-"],
63
66
  ["api key", model ? api_key_for(model, env) : "-"],
64
67
  ["loop", model ? loop_for(model, env) : "-"],
@@ -187,6 +190,35 @@ module Samagotchi
187
190
  ModelOverlay.key_for(model) || "-"
188
191
  end
189
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
+
190
222
  def model_name
191
223
  ModelProfile.required_model_name
192
224
  rescue ArgumentError
@@ -7,6 +7,7 @@ require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
9
  require_relative "llm_context_override"
10
+ require_relative "prompt_note"
10
11
 
11
12
  require_relative "paths"
12
13
  require_relative "project_scope"
@@ -44,6 +45,17 @@ module Samagotchi
44
45
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
45
46
  # Names as given; the engine normalizes them.
46
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
47
59
  # The name model_name was typed as when that differs from the resolved
48
60
  # ref stored in model_name (an alias: `small` for `gemma-small`), else
49
61
  # nil. The models: lookup takes it (HostRegistry#lookup_names).
@@ -118,7 +130,7 @@ module Samagotchi
118
130
  first_preview: "", test_run: false, pending_question: nil,
119
131
  used_memory_names: [], project_root: nil,
120
132
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
121
- last_turn: nil, model_typed: nil, delegate: false, llm_context: nil)
133
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [])
122
134
  @id = id
123
135
  @metadata_version = metadata_version
124
136
  @mode = mode
@@ -136,6 +148,7 @@ module Samagotchi
136
148
  @used_memory_names = self.class.name_list(used_memory_names)
137
149
  @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
138
150
  @muted_memory_names = self.class.name_list(muted_memory_names)
151
+ self.prompt_notes = prompt_notes
139
152
  @project_root = project_root
140
153
  @parent_id = parent_id&.to_s
141
154
  @scratch = !!scratch
@@ -269,6 +282,7 @@ module Samagotchi
269
282
  "project_root" => nil,
270
283
  "preloaded_memory_names" => [],
271
284
  "muted_memory_names" => [],
285
+ "prompt_notes" => [],
272
286
  "parent_id" => nil,
273
287
  "scratch" => false,
274
288
  "last_turn" => nil,
@@ -393,6 +407,7 @@ module Samagotchi
393
407
  when "test_run" then !!@test_run
394
408
  when "delegate" then @delegate
395
409
  when "llm_context" then llm_context_file
410
+ when "prompt_notes" then @prompt_notes.map(&:to_file)
396
411
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
397
412
  Array(instance_variable_get(:"@#{key}"))
398
413
  else instance_variable_get(:"@#{key}")
@@ -3,6 +3,7 @@
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"
@@ -110,6 +111,14 @@ module Samagotchi
110
111
  @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
111
112
  end
112
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
+
113
122
  # @return [String] the model /model clear goes back to
114
123
  attr_reader :default_model
115
124
 
@@ -362,9 +371,9 @@ module Samagotchi
362
371
  def model_command(input)
363
372
  suffix = input.delete_prefix(MODEL_COMMAND).strip
364
373
  if suffix.empty?
365
- 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)
366
375
 
367
- 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]
368
377
  end
369
378
 
370
379
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -434,14 +443,14 @@ module Samagotchi
434
443
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
435
444
 
436
445
  switch_model(@default_model)
437
- return ["runtime model reset to #{model_name}#{model_note}", true]
446
+ return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
438
447
  end
439
448
 
440
449
  switch_model(arg, persist_default: persist_default)
441
450
  if persist_default
442
- ["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]
443
452
  else
444
- ["runtime model set to #{model_name}#{model_note}", true]
453
+ ["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
445
454
  end
446
455
  end
447
456
  end
@@ -482,6 +491,16 @@ module Samagotchi
482
491
  ""
483
492
  end
484
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
+
485
504
  # "; thinking: off (models: qwen)" when the model has a level set.
486
505
  def thinking_note
487
506
  summary = @engine.thinking_summary
@@ -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.
@@ -308,7 +310,7 @@ module Samagotchi
308
310
  when :turn_canceled
309
311
  end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
310
312
  when :turn_failed
311
- end_turn(status: "failed")
313
+ end_turn(status: "failed", failure: failure_fields(event))
312
314
  end
313
315
 
314
316
  @mutex.synchronize { @last_activity_at = now.iso8601(3) }
@@ -497,9 +499,10 @@ module Samagotchi
497
499
  end
498
500
 
499
501
  # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
500
- # and who stopped it (+by+: a hook's bundle), so a reloaded web history
501
- # can show the cancel line the live one did.
502
- 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)
503
506
  @mutex.synchronize do
504
507
  if @turn
505
508
  # A turn that failed mid-stream never saw its generation end:
@@ -522,6 +525,7 @@ module Samagotchi
522
525
  }
523
526
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
524
527
  record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
528
+ record[:failure] = failure if failure
525
529
  record.merge!(turn_token_fields(@turn))
526
530
  @turn_records << record
527
531
  count_turn(record)
@@ -530,6 +534,19 @@ module Samagotchi
530
534
  end
531
535
  end
532
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
+
533
550
  # The model the server says answered, and the name asked for then (a
534
551
  # /model switch makes it stale until the next generation reports).
535
552
  def record_served_model(event)
@@ -80,13 +80,17 @@ module Samagotchi
80
80
  key = [chat, thinking, layers]
81
81
  @built ||= {}
82
82
  @index_sizes ||= {}
83
+ @notes_loaded ||= {}
83
84
  unless @built.key?(key)
85
+ @section_notes = nil
84
86
  @built[key] = system_prompt_with_index(
85
87
  assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
86
88
  )
87
89
  @index_sizes[key] = @measured_index
90
+ @notes_loaded[key] = @section_notes || []
88
91
  end
89
92
  @memory_index = @index_sizes[key]
93
+ @prompt_notes = @notes_loaded[key]
90
94
  @built[key]
91
95
  end
92
96
 
@@ -98,11 +102,19 @@ module Samagotchi
98
102
  # @return [Hash, nil]
99
103
  attr_reader :memory_index
100
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
+
101
111
  # Drops the built prompts: the next #build reads the profile, tools,
102
112
  # indexes and memories again (a model or profile switch, changed tools).
103
113
  def reset!
104
114
  @built = nil
105
115
  @index_sizes = nil
116
+ @notes_loaded = nil
117
+ @prompt_notes = nil
106
118
  @stable_lengths = nil
107
119
  end
108
120
 
@@ -323,6 +335,7 @@ module Samagotchi
323
335
  # identity: a heading per scope, each note under its name, the models:
324
336
  # line left out. nil without any, so the prompt stays as it was.
325
337
  def model_notes_section
338
+ @section_notes = []
326
339
  notes = Array(@model_notes_lookup.call)
327
340
  return nil if notes.empty?
328
341
 
@@ -330,7 +343,7 @@ module Samagotchi
330
343
  notes.group_by(&:scope).map do |scope, list|
331
344
  heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
332
345
  [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
333
- end.join("\n\n")
346
+ end.join("\n\n").tap { @section_notes = notes }
334
347
  rescue StandardError
335
348
  nil
336
349
  end
@@ -522,6 +522,9 @@ module Samagotchi
522
522
  return
523
523
  end
524
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
525
528
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
526
529
 
527
530
  detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
@@ -828,6 +831,14 @@ module Samagotchi
828
831
  # so it shows above what it got.
829
832
  def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
830
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
+
831
842
  def stale_worker(cant)
832
843
  BridgeClient.stale_worker_message(@client.session_id, cant: cant)
833
844
  end
@@ -1002,6 +1013,7 @@ module Samagotchi
1002
1013
 
1003
1014
  why =
1004
1015
  if reply.status == 404 then stale_worker("run commands")
1016
+ elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
1005
1017
  elsif too_late?(reply) then LATE
1006
1018
  else "the worker answered #{reply.status}"
1007
1019
  end
@@ -6,6 +6,7 @@ require_relative "../image_store"
6
6
  require_relative "../turn_note"
7
7
  require_relative "../steer"
8
8
  require_relative "../memory_bundle/index_size"
9
+ require_relative "../prompt_note"
9
10
 
10
11
  module Samagotchi
11
12
  class TerminalUI
@@ -485,6 +486,9 @@ module Samagotchi
485
486
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
486
487
  memory_index = memory_index_text(snapshot[:memory_index])
487
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?
488
492
  llm_context = llm_context_stats_text(snapshot[:llm_context])
489
493
  lines << "llm context: #{llm_context}" if llm_context
490
494
  if snapshot[:served_model]
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "tools/args"
5
5
  require_relative "llm_context_strategy"
6
+ require_relative "tools/memory"
6
7
 
7
8
  module Samagotchi
8
9
  # Tool-declaration constants, protocol constants, and guidance text.
@@ -146,7 +147,7 @@ module Samagotchi
146
147
  },
147
148
  description: {
148
149
  type: "string",
149
- description: "Optional one-line description (at most 200 characters) appended to the managed index line; without content, it changes only that line"
150
+ description: "Optional one-line description (at most #{Tools::MemoryWrite::DESCRIPTION_LIMIT} characters) appended to the managed index line; without content, it changes only that line"
150
151
  },
151
152
  current_model_only: {
152
153
  type: "boolean",
@@ -46,11 +46,18 @@ module Samagotchi
46
46
  # (context summarizes the interrupted turn for a "no, <reason>")
47
47
  attr_reader :offer
48
48
 
49
+ # How many times the conversation went back to a checkpoint (a failed
50
+ # or canceled turn's restore, !rollback): a worker compares it across a
51
+ # turn to tell whether the turn's messages (the delegate reports it
52
+ # merged) left the conversation.
53
+ attr_reader :restores
54
+
49
55
  def initialize(engine:)
50
56
  @engine = engine
51
57
  @checkpoint = nil
52
58
  @continue_checkpoint = nil
53
59
  @offer = nil
60
+ @restores = 0
54
61
  end
55
62
 
56
63
  def awaiting_continue? = !@offer.nil?
@@ -198,6 +205,7 @@ module Samagotchi
198
205
  restored = Array(checkpoint) + arrived
199
206
  restored = TurnNote.replace_trailing(restored, note) if note
200
207
  @engine.rollback_to(restored)
208
+ @restores += 1
201
209
  end
202
210
 
203
211
  def conversation_of(result)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.41.0"
4
+ VERSION = "0.43.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -20,6 +20,7 @@ require_relative "../session_metrics"
20
20
  require_relative "../session_commands"
21
21
  require_relative "../steer"
22
22
  require_relative "../host_registry"
23
+ require_relative "../llm_context_override"
23
24
  require_relative "../model_catalog"
24
25
  require_relative "../model_profile"
25
26
  require_relative "../sampling_settings"
@@ -458,7 +459,8 @@ module Samagotchi
458
459
  end
459
460
 
460
461
  # Each model's configured sampling as /model words it ("temperature=0.6
461
- # (hosts.work)"), on the models that have some: the picker's tooltip.
462
+ # (hosts.work)"), on the models that have some: the picker's tooltip;
463
+ # and each model's LLM context (LLMContextStrategy::Explained#summary).
462
464
  # A config that can't be read leaves the list as it is.
463
465
  def add_sampling(models, registry)
464
466
  return if registry.nil? || models.empty?
@@ -466,14 +468,23 @@ module Samagotchi
466
468
  settings = ConfigFile.model_settings
467
469
  models.each do |model|
468
470
  target = registry.resolve(model[:name])
469
- summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
470
- models: settings)
471
+ names = registry.lookup_names(model[:name], target: target)
472
+ summary = SamplingSettings.summary(target, names: names, models: settings)
471
473
  model[:sampling] = summary if summary
474
+ add_llm_context(model, target, names, settings)
472
475
  end
473
476
  rescue StandardError
474
477
  nil
475
478
  end
476
479
 
480
+ # The LLM context +model+ starts under (the start page's llm ctx chip);
481
+ # one that can't be read leaves this model without, not the others.
482
+ def add_llm_context(model, target, names, settings)
483
+ model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
484
+ rescue StandardError
485
+ nil
486
+ end
487
+
477
488
  # A session's LLM context strategy, apply rule and budget, each with
478
489
  # where it came from (LLMContextStrategy::Explained#summary), as its
479
490
  # worker would resolve them; nil when they can't be.
@@ -563,6 +574,12 @@ module Samagotchi
563
574
  dir, error = scope_dir(body["dir"])
564
575
  return error if error
565
576
 
577
+ # llm_context: the session's own LLM context values before its first
578
+ # turn (the start page's llm ctx chip; chi --llm-context), read
579
+ # before anything spawns so a bad one spawns nothing.
580
+ llm_context, error = request_llm_context(body["llm_context"])
581
+ return error if error
582
+
566
583
  folder = dir ? { working_directory: dir } : {}
567
584
  # model: the model the session starts on (the start page's picker,
568
585
  # spelled as GET /api/models lists it); blank means the default.
@@ -573,6 +590,7 @@ module Samagotchi
573
590
  # until that turn is saved.
574
591
  preview = idle ? body["preview"].to_s.strip : ""
575
592
  folder[:title] = preview unless preview.empty?
593
+ folder[:llm_context] = llm_context if llm_context
576
594
  begin
577
595
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
578
596
  rescue ModelProfile::MissingModel => e
@@ -588,6 +606,25 @@ module Samagotchi
588
606
  json_response(201, session_to_json(session).merge(bridge_port: port))
589
607
  end
590
608
 
609
+ # The create request's "llm_context" ({strategy:, apply:, budget:},
610
+ # each a word as /llm-context takes it, "default" for unset) as an
611
+ # override: [override, nil] (nil when absent or all default), or
612
+ # [nil, a 400 response] for anything else.
613
+ def request_llm_context(raw)
614
+ return [nil, nil] if raw.nil?
615
+
616
+ fields = LLMContextOverride::COMMAND_WORDS.invert
617
+ unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
618
+ return [nil, error_response(400, "invalid_llm_context",
619
+ "llm_context is an object of #{fields.keys.join(", ")} words")]
620
+ end
621
+
622
+ override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
623
+ [override.empty? ? nil : override, nil]
624
+ rescue ArgumentError => e
625
+ [nil, error_response(400, "invalid_llm_context", e.message)]
626
+ end
627
+
591
628
  def handle_show(req, id)
592
629
  # The page's light re-reads: never the whole conversation.
593
630
  return handle_cards_read(id) if req.params["cards"] == "1"
@@ -626,6 +663,8 @@ module Samagotchi
626
663
  # What the worker's server said it served for that model (after a turn).
627
664
  if snapshot
628
665
  session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
666
+ # The model notes its prompt carries now (a /model there changes them).
667
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
629
668
  end
630
669
  # The LLM context strategy the next turn runs under (the info bar's
631
670
  # chip): the worker's, else worked out here from the session file.
@@ -680,8 +719,9 @@ module Samagotchi
680
719
  # history again (nor a render of every earlier answer). Live: the
681
720
  # worker's GET tail alone (no session file read); else the file. The
682
721
  # session part is what the page reads of it (id, status, used memory
683
- # names). ?turn_id=: that turn's answer (a queued turn's page re-reads
684
- # after the next one started). ?recent=1 (the page since this
722
+ # names, the model notes its prompt carried: the first turn's build or
723
+ # a /model records them). ?turn_id=: that turn's answer (a queued
724
+ # turn's page re-reads after the next one started). ?recent=1 (the page since this
685
725
  # release): the timing's records are the newest turn's (#recent_timing),
686
726
  # which the page merges by id; without it, the whole timing as before.
687
727
  def handle_tail_read(req, id)
@@ -692,12 +732,14 @@ module Samagotchi
692
732
  tail = live_tail(id, turn_id: turn_id)
693
733
  state = tail && tail["session_state_snapshot"]
694
734
  if state.is_a?(Hash)
695
- session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]) }
735
+ session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]),
736
+ prompt_notes: Array(state["prompt_notes"]) }
696
737
  messages = last_assistant_for_display([tail["answer"]].compact)
697
738
  else
698
739
  session = @session_class.load(id, state_dir: default_state_dir)
699
740
  state = nil
700
- session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names) }
741
+ session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
742
+ prompt_notes: session.prompt_notes.map(&:to_h) }
701
743
  messages = last_assistant_for_display(session.messages, turn_id: turn_id)
702
744
  end
703
745
  live_metrics = state && state["metrics"]
@@ -839,7 +881,12 @@ module Samagotchi
839
881
  when 202
840
882
  record_history(body, line) if PromptHistory.shell_line?(line)
841
883
  json_response(202, reply.json || { status: "accepted" })
842
- when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
884
+ when 400
885
+ # One of this chi's own commands the worker doesn't know: it runs an older chi.
886
+ name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
887
+ next error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run #{name}")) if name
888
+
889
+ error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
843
890
  end
844
891
  end
845
892
  rescue SessionManager::OwnedByTUI => e