samagotchi 0.13.0 → 0.15.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +4 -7
  4. data/docs/configuration.md +50 -7
  5. data/docs/guardrails.md +2 -1
  6. data/docs/plugins.md +28 -13
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +94 -6
  10. data/lib/samagotchi/bridge.rb +3 -1
  11. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +31 -6
  17. data/lib/samagotchi/context_window.rb +30 -5
  18. data/lib/samagotchi/engine.rb +21 -7
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
  20. data/lib/samagotchi/host_registry.rb +31 -6
  21. data/lib/samagotchi/kernel_loop.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  23. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/self_report.rb +24 -4
  26. data/lib/samagotchi/session_manager.rb +8 -0
  27. data/lib/samagotchi/session_metrics.rb +1 -1
  28. data/lib/samagotchi/session_retention.rb +6 -1
  29. data/lib/samagotchi/system_prompt.rb +1 -1
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
  31. data/lib/samagotchi/terminal_ui.rb +27 -3
  32. data/lib/samagotchi/tools/delegate_wait.rb +17 -10
  33. data/lib/samagotchi/tools/memory.rb +3 -3
  34. data/lib/samagotchi/utf8_default.rb +29 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +22 -7
  37. data/lib/samagotchi/web/public/activity.js +6 -1
  38. data/lib/samagotchi/web/public/app.js +46 -10
  39. data/lib/samagotchi/web/public/index.html +10 -1
  40. data/lib/samagotchi/web/public/notify.js +16 -3
  41. data/lib/samagotchi/web/public/question_cards.js +3 -1
  42. data/lib/samagotchi/web/public/sessions_list.js +45 -0
  43. data/lib/samagotchi/web/public/strip.js +8 -1
  44. data/lib/samagotchi/web/public/timing.js +11 -2
  45. data/lib/samagotchi/web/public/turn_view.js +10 -10
  46. metadata +3 -1
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.3.2
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:61b195a5621ddf57ba69a505aff06d620d6459e836d736205343a251b0fecb71
10
+ sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
11
11
  requires_chi: ">= 0.8.0"
@@ -13,15 +13,18 @@
13
13
  # cwd: ~/scratch # default: where chi runs
14
14
  # tools: [echo, add] # optional: only these (globs work)
15
15
  # timeout: 120 # optional: this server's per-call timeout
16
- # attach_image_paths: true # default: a result that is only the path of an
17
- # # image in the temp dir or cwd attaches it
16
+ # attach_image_paths: true # default: an image path in the text, in the
17
+ # # temp dir or cwd, attaches it
18
18
  #
19
19
  # Image blocks in a result go to the model as images (text: "[image 1:
20
- # image/png, attached]"); so does a text block that is only the absolute
21
- # path of an image file under the system temp dir or the server's cwd
22
- # (chrome-devtools-mcp --slim answers a screenshot that way).
20
+ # image/png, attached]"); so does an image file named in a text block,
21
+ # under the system temp dir or the server's cwd: the block is only its
22
+ # absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
23
+ # or it says one ending in an image extension ("Saved it to /tmp/a.png.").
23
24
  # A server that doesn't start, answer or list its tools is skipped with a
24
- # notice; the rest of chi works. /mcp lists the servers and their tools.
25
+ # notice; the rest of chi works. One that exits mid-session starts again on
26
+ # its next call, at most MAX_RESTARTS times a session. /mcp lists the
27
+ # servers and their tools.
25
28
  require "digest"
26
29
  require "json"
27
30
  require "open3"
@@ -38,6 +41,8 @@ class Plugin
38
41
  NAME_CHARS = 48
39
42
  # A cached tool list older than this is refreshed in the background.
40
43
  CACHE_TTL = 24 * 60 * 60
44
+ # Restarts of a server that exited, per session.
45
+ MAX_RESTARTS = 3
41
46
  IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
42
47
 
43
48
  # A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
@@ -60,9 +65,13 @@ class Plugin
60
65
  # @param log [#call] (event, fields) for stderr lines and protocol noise
61
66
  # @param on_exit [#call, nil] called once, with the reason, when the
62
67
  # process ends while the client is open
63
- def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil)
68
+ # @param on_notification [#call, nil] (method, params) for each
69
+ # notification from the server, on the reader thread: it must not
70
+ # wait for an answer there
71
+ def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil, on_notification: nil)
64
72
  @log = log
65
73
  @on_exit = on_exit
74
+ @on_notification = on_notification
66
75
  @mutex = Mutex.new
67
76
  @write_mutex = Mutex.new
68
77
  @pending = {}
@@ -179,8 +188,11 @@ class Plugin
179
188
  def dispatch(message)
180
189
  if message.key?("method")
181
190
  # A request from the server (ping, roots/list, …): ping is answered,
182
- # the rest aren't supported. A notification is logged.
183
- return @log.call("server_notification", method: message["method"]) unless message.key?("id")
191
+ # the rest aren't supported. A notification is logged and handed on.
192
+ unless message.key?("id")
193
+ @log.call("server_notification", method: message["method"])
194
+ return @on_notification&.call(message["method"], message["params"])
195
+ end
184
196
 
185
197
  answer = if message["method"] == "ping"
186
198
  { jsonrpc: "2.0", id: message["id"], result: {} }
@@ -227,8 +239,10 @@ class Plugin
227
239
  # One configured server: its service, state and tools. +listed+ is its
228
240
  # tools/list as the server answers it (what the cache keeps), +tools+
229
241
  # the ones chi offers (the tools: filter applied, each with chi_name).
242
+ # +client+ is the running process's (a restart replaces it; the
243
+ # service's stop closes the current one), +restarts+ how many it had.
230
244
  Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
231
- :digest, :cached_at, keyword_init: true)
245
+ :digest, :cached_at, :relist, :relisting, :client, :restarts, keyword_init: true)
232
246
 
233
247
  def initialize(settings = {})
234
248
  @settings = settings
@@ -236,6 +250,8 @@ class Plugin
236
250
  @startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
237
251
  @servers = []
238
252
  @publish = Mutex.new
253
+ @relist = Mutex.new
254
+ @restart = Mutex.new
239
255
  # The tools left out so far (a name clash): said once, not at each
240
256
  # publish.
241
257
  @left_out = []
@@ -255,7 +271,8 @@ class Plugin
255
271
  configs = @settings["servers"]
256
272
  configs = {} unless configs.is_a?(Hash)
257
273
  @servers = configs.map do |name, config|
258
- server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [])
274
+ server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
275
+ restarts: 0)
259
276
  server.timeout = positive(server.config["timeout"]) || @timeout
260
277
  resolve(server, ctx)
261
278
  server.service = chi.service(name) { |svc| start(server, svc, ctx) }
@@ -306,16 +323,29 @@ class Plugin
306
323
  server.digest = Digest::SHA256.hexdigest(JSON.generate([server.command, server.env.sort, server.cwd]))
307
324
  end
308
325
 
309
- # The service's start: spawn, initialize, list the tools (cancelled with
310
- # the turn that waits for it), then cache the list.
326
+ # The service's start: spawn (#spawn). Its stop closes the server's
327
+ # current process, a restarted one too.
311
328
  def start(server, svc, ctx)
312
329
  raise Client::Error, "no command (bundles: mcp: servers: #{server.name}: command: [...])" if server.command.empty?
313
330
 
331
+ svc.on_stop { server.client&.close }
332
+ spawn(server, ctx)
333
+ end
334
+
335
+ # Spawn the server's process (it is server.client from then on),
336
+ # initialize, list the tools (cancelled with the turn that waits for
337
+ # it), then cache the list.
338
+ # @return [Client]
339
+ def spawn(server, ctx)
314
340
  cancelled = -> { ctx.cancelled? }
315
- client = Client.new(server.command, env: server.env, cwd: server.cwd,
316
- log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
317
- on_exit: ->(reason) { exited(server, reason, ctx) })
318
- svc.on_stop { client.close }
341
+ client = server.client = Client.new(
342
+ server.command, env: server.env, cwd: server.cwd,
343
+ log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
344
+ on_exit: ->(reason) { exited(server, reason, ctx) },
345
+ on_notification: lambda { |method, _params|
346
+ tools_changed(server, ctx) if method == "notifications/tools/list_changed"
347
+ }
348
+ )
319
349
  client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
320
350
  clientInfo: { name: "chi", version: Samagotchi::VERSION } },
321
351
  timeout: @startup_timeout, cancelled: cancelled)
@@ -380,6 +410,46 @@ class Plugin
380
410
  nil
381
411
  end
382
412
 
413
+ # notifications/tools/list_changed from a running server: list its tools
414
+ # again (on a thread of its own: the answer comes on the reader thread
415
+ # that told us), save them and replace the tools for the next turn.
416
+ # Notices that come together list once more after the one running.
417
+ def tools_changed(server, ctx)
418
+ @relist.synchronize do
419
+ server.relist = true
420
+ return if server.relisting
421
+
422
+ server.relisting = true
423
+ end
424
+ Thread.new do
425
+ relist(server, ctx) while relist_pending?(server)
426
+ end
427
+ end
428
+
429
+ # Takes the server's pending relist: true once per notice; false (and
430
+ # the relisting thread ends) when none is left.
431
+ def relist_pending?(server)
432
+ @relist.synchronize do
433
+ pending = server.relist
434
+ server.relist = false
435
+ server.relisting = false unless pending
436
+ pending
437
+ end
438
+ end
439
+
440
+ def relist(server, ctx)
441
+ server.service.value # raises once stopped
442
+ listed = list_tools(server.client, nil)
443
+ return if listed == server.listed
444
+
445
+ server.listed = listed
446
+ save_cache(server, ctx)
447
+ ctx.log.info("mcp_tools_changed", server: server.name, tools: listed.size)
448
+ publish(ctx)
449
+ rescue StandardError => e
450
+ ctx.log.warn("mcp_tools_not_relisted", server: server.name, error: e.class.name, msg: e.message)
451
+ end
452
+
383
453
  # tools/list, every page.
384
454
  def list_tools(client, cancelled)
385
455
  tools = []
@@ -456,7 +526,7 @@ class Plugin
456
526
  def publish(ctx)
457
527
  @publish.synchronize do
458
528
  @chi.replace_tools do |set|
459
- @servers.each { |server| declare(set, server, ctx) if %i[running cached].include?(server.state) }
529
+ @servers.each { |server| declare(set, server, ctx) if %i[running cached exited].include?(server.state) }
460
530
  end
461
531
  end
462
532
  end
@@ -483,7 +553,13 @@ class Plugin
483
553
  def call(server, tool, args, ctx)
484
554
  return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
485
555
 
486
- client = server.state == :cached ? lazy_start(server, ctx) : server.service.value
556
+ client = case server.state
557
+ when :cached then lazy_start(server, ctx)
558
+ when :exited then restart(server, ctx)
559
+ else
560
+ server.service.value # raises once stopped
561
+ server.client
562
+ end
487
563
  return client if client.is_a?(String)
488
564
 
489
565
  result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
@@ -529,6 +605,36 @@ class Plugin
529
605
  "Error: MCP server #{server.name} didn't start: #{e.message}"
530
606
  end
531
607
 
608
+ # Start a server that exited again, for a call: at most MAX_RESTARTS
609
+ # times a session. A start that fails or is cancelled leaves it exited
610
+ # (the next call tries again, while restarts are left).
611
+ # @return [Client, String] the client, or the call's error text
612
+ def restart(server, ctx)
613
+ @restart.synchronize do
614
+ server.service.value # raises once stopped
615
+ return server.client if server.state == :running # another call restarted it
616
+
617
+ if server.restarts >= MAX_RESTARTS
618
+ return "Error: MCP server #{server.name} is not running (#{server.error}; restarted #{MAX_RESTARTS} times this session)"
619
+ end
620
+
621
+ server.restarts += 1
622
+ cached = server.listed
623
+ server.client&.close
624
+ begin
625
+ client = spawn(server, ctx)
626
+ rescue StandardError => e
627
+ server.client&.close
628
+ ctx.log.warn("mcp_server_not_restarted", server: server.name, error: e.class.name, msg: e.message)
629
+ return "Error: MCP server #{server.name} didn't restart: #{e.message}"
630
+ end
631
+ server.state = :running
632
+ ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
633
+ publish(ctx) if server.listed != cached
634
+ client
635
+ end
636
+ end
637
+
532
638
  # The answer's text, and the images to attach: its image blocks, and a
533
639
  # text block that is only an image's path (#image_path).
534
640
  # @return [Array(String, Array<Hash>)]
@@ -541,11 +647,14 @@ class Plugin
541
647
  case block["type"]
542
648
  when "text"
543
649
  line = block["text"].to_s
544
- path = image_path(line, server)
545
- next line unless path
650
+ paths = image_paths(line, server).reject { |path| images.any? { |image| image[:path] == path } }
651
+ next line if paths.empty?
546
652
 
547
- images << { path: path, name: File.basename(path) }
548
- "#{line}\n[image #{images.size}: #{File.basename(path)}, attached]"
653
+ notes = paths.map do |path|
654
+ images << { path: path, name: File.basename(path) }
655
+ "[image #{images.size}: #{File.basename(path)}, attached]"
656
+ end
657
+ [line, *notes].join("\n")
549
658
  when "image"
550
659
  mime = block["mimeType"] || "unknown type"
551
660
  bytes = block["data"].to_s.unpack1("m")
@@ -564,14 +673,31 @@ class Plugin
564
673
  [text, images]
565
674
  end
566
675
 
567
- # The path when +text+ is only the absolute path of an image file under
568
- # the system temp dir or the server's cwd (a server's text can't pull
569
- # in any image on disk), and the server's attach_image_paths isn't off.
570
- def image_path(text, server)
571
- return nil if server.config["attach_image_paths"] == false
676
+ # An absolute path ending in an image extension, inside a sentence
677
+ # ("Saved screenshot to /tmp/shot.png."): no spaces, quotes or brackets.
678
+ IMAGE_PATH_IN_TEXT = %r{(?<![\w/.~-])/[^\s"'`<>()\[\]{},;]+?\.(?:png|jpe?g|gif|webp)(?![\w/-])}i
679
+
680
+ # The images +text+ names, when the server's attach_image_paths isn't
681
+ # off: the whole text when it is one absolute path (any name), else each
682
+ # absolute path in it that ends in an image extension. Each must be an
683
+ # image file (by its bytes) under the system temp dir or the server's
684
+ # cwd: a server's text can't pull in any image on disk.
685
+ # @return [Array<String>] real paths, each once
686
+ def image_paths(text, server)
687
+ return [] if server.config["attach_image_paths"] == false
688
+
689
+ whole = text.strip
690
+ candidates = if whole.start_with?("/") && !whole.include?("\n") && File.file?(whole)
691
+ [whole]
692
+ else
693
+ text.scan(IMAGE_PATH_IN_TEXT)
694
+ end
695
+ candidates.filter_map { |path| image_path(path, server) }.uniq
696
+ end
572
697
 
573
- path = text.strip
574
- return nil unless path.start_with?("/") && !path.include?("\n") && File.file?(path)
698
+ # +path+'s real path when it is an image file under a root, else nil.
699
+ def image_path(path, server)
700
+ return nil unless File.file?(path)
575
701
 
576
702
  real = File.realpath(path)
577
703
  roots = [Dir.tmpdir, server.cwd].compact.map { |dir| File.realpath(dir) rescue nil }.compact
@@ -589,14 +715,20 @@ class Plugin
589
715
  (head.start_with?("RIFF") && head[8, 4] == "WEBP")
590
716
  end
591
717
 
592
- # The process ended while chi runs: one notice; the calls say so.
718
+ # The process ended while chi runs: one notice; its next call starts it
719
+ # again (#restart) while restarts are left.
593
720
  def exited(server, reason, ctx)
594
721
  return unless server.state == :running
595
722
 
596
723
  server.state = :exited
597
724
  server.error = reason
598
- ctx.log.warn("mcp_server_exited", server: server.name, msg: reason)
599
- ctx.notify("MCP server #{server.name} stopped: #{reason}; its tools fail until chi restarts", level: :warn)
725
+ ctx.log.warn("mcp_server_exited", server: server.name, msg: reason, restarts: server.restarts)
726
+ after = if server.restarts < MAX_RESTARTS
727
+ "it restarts on its next call"
728
+ else
729
+ "it was restarted #{MAX_RESTARTS} times, its tools fail until chi restarts"
730
+ end
731
+ ctx.notify("MCP server #{server.name} stopped: #{reason}; #{after}", level: :warn)
600
732
  end
601
733
 
602
734
  def listing
@@ -614,7 +746,7 @@ class Plugin
614
746
  when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
615
747
  when :running
616
748
  count = server.tools.count { |tool| tool["chi_name"] }
617
- "running (pid #{server.service.value.pid}), #{count} tool#{"s" unless count == 1}"
749
+ "running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}"
618
750
  when :starting then "starting"
619
751
  else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
620
752
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.13.0
3
+ version: 0.15.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -187,8 +187,9 @@ module Samagotchi
187
187
  # Maps of named entries and the keys an entry may hold
188
188
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
189
189
  MAP_ENTRY_KEYS = {
190
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled].freeze,
191
- "models" => %w[profile vision sampling thinking].freeze
190
+ "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
191
+ remote window_tokens].freeze,
192
+ "models" => %w[profile vision sampling thinking window_tokens].freeze
192
193
  }.freeze
193
194
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
194
195
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
@@ -603,7 +604,23 @@ module Samagotchi
603
604
 
604
605
  # A `vision:` setting (hosts entry or models: entry): true, false, or nil
605
606
  # when unset. Anything else warns once and counts as unset.
606
- def vision_flag(value, where)
607
+ def vision_flag(value, where) = bool_flag(value, where, "vision")
608
+
609
+ # A `window_tokens:` setting (hosts entry or models: entry): a positive
610
+ # integer, nil when unset; anything else warns once and counts as unset.
611
+ def window_tokens(value, where)
612
+ return nil if value.nil?
613
+
614
+ tokens = Integer(value.to_s.strip, exception: false)
615
+ return tokens if tokens&.positive?
616
+
617
+ warn_once "Warning: #{where}: window_tokens must be a positive number of tokens; ignored"
618
+ nil
619
+ end
620
+
621
+ # A true/false setting +key+ of a map entry, nil when unset; anything
622
+ # else warns once and counts as unset.
623
+ def bool_flag(value, where, key)
607
624
  return nil if value.nil?
608
625
  return value if value == true || value == false
609
626
 
@@ -611,7 +628,7 @@ module Samagotchi
611
628
  return true if text == "true"
612
629
  return false if text == "false"
613
630
 
614
- warn_once "Warning: #{where}: vision must be true or false; ignored"
631
+ warn_once "Warning: #{where}: #{key} must be true or false; ignored"
615
632
  nil
616
633
  end
617
634
 
@@ -729,6 +746,10 @@ module Samagotchi
729
746
  profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
730
747
  first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
731
748
  vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
749
+ remote = ConfigFile.bool_flag(raw_cfg.key?("remote") ? raw_cfg["remote"] : raw_cfg[:remote], "hosts entry '#{name}'",
750
+ "remote")
751
+ window = ConfigFile.window_tokens(raw_cfg.key?("window_tokens") ? raw_cfg["window_tokens"] : raw_cfg[:window_tokens],
752
+ "hosts entry '#{name}'")
732
753
  sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
733
754
  thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
734
755
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
@@ -794,7 +815,8 @@ module Samagotchi
794
815
  api: api_val&.to_sym, original_name: name, scheme: scheme,
795
816
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
796
817
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
797
- vision: vision, sampling: sampling, thinking: thinking }
818
+ vision: vision, sampling: sampling, thinking: thinking, remote: remote,
819
+ window_tokens: window }
798
820
  end
799
821
  end
800
822
 
@@ -935,7 +957,8 @@ module Samagotchi
935
957
  location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
936
958
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
937
959
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
938
- "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
960
+ "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
961
+ "remote" => v[:remote], "window_tokens" => v[:window_tokens]).compact
939
962
  end
940
963
  # Disabled hosts travel as just that, so a worker refuses "box:x"
941
964
  # the way its parent does instead of sending it to the default host.
@@ -1023,6 +1046,8 @@ module Samagotchi
1023
1046
  result[key][:sampling] = sampling if sampling
1024
1047
  thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
1025
1048
  result[key][:thinking] = thinking if thinking
1049
+ window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
1050
+ result[key][:window_tokens] = window if window
1026
1051
  end
1027
1052
  rescue StandardError
1028
1053
  {}
@@ -9,11 +9,16 @@ module Samagotchi
9
9
  # 1. the running server (Client#context_window, e.g. llama.cpp's n_ctx)
10
10
  # 2. the host's model list (a chat adapter's #context_window, e.g. a
11
11
  # provider's context_length)
12
- # 3. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
13
- # config file); it only fills in when the server reports nothing
14
- # 4. DEFAULT_TOKENS
12
+ # 3. models.<key>.window_tokens, else hosts.<name>.window_tokens
13
+ # (#setting: the most specific wins)
14
+ # 4. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
15
+ # config file)
16
+ # 5. DEFAULT_TOKENS
17
+ # Settings (3 and 4) only fill in when the server and its list report
18
+ # nothing.
15
19
  #
16
- # Sources: :server, :model_list, :config (CLI or file), :env, :default.
20
+ # Sources: :server, :model_list, :model_setting, :host_setting, :config
21
+ # (CLI or file), :env, :default.
17
22
  module ContextWindow
18
23
  DEFAULT_TOKENS = 256_000
19
24
 
@@ -22,7 +27,8 @@ module Samagotchi
22
27
  module_function
23
28
 
24
29
  # @param adapter [#context_window, nil] the host's chat adapter
25
- def resolve(client: nil, model: nil, adapter: nil)
30
+ # @param setting [Resolved, nil] the model's or host's window (#setting)
31
+ def resolve(client: nil, model: nil, adapter: nil, setting: nil)
26
32
  server_tokens = client.context_window(model: model) if client.respond_to?(:context_window)
27
33
  if positive_integer?(server_tokens)
28
34
  @last_server = Resolved.new(tokens: server_tokens, source: :server)
@@ -34,10 +40,29 @@ module Samagotchi
34
40
  @last_server = Resolved.new(tokens: listed, source: :model_list)
35
41
  return @last_server
36
42
  end
43
+ return @last_server = setting if setting
37
44
 
38
45
  configured
39
46
  end
40
47
 
48
+ # The window config.yml gives this model or its host:
49
+ # models.<key>.window_tokens (by the model's lookup names, as sampling
50
+ # and vision look a model up), else hosts.<name>.window_tokens.
51
+ # @param target [HostRegistry::ModelTarget]
52
+ # @param names [Array<String>] HostRegistry#lookup_names
53
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
54
+ # @return [Resolved, nil]
55
+ def setting(target, names:, models: nil)
56
+ models ||= ConfigFile.model_settings
57
+ _key, tokens = ConfigFile.model_setting(names, :window_tokens, models: models)
58
+ return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
59
+
60
+ tokens = target&.entry&.window_tokens
61
+ positive_integer?(tokens) ? Resolved.new(tokens: tokens, source: :host_setting) : nil
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
41
66
  # The window without asking a server: config, env, then the default.
42
67
  # `env` resolves against that env's config file instead of the live
43
68
  # process config (for `chi self`).
@@ -1362,8 +1362,10 @@ module Samagotchi
1362
1362
  # +ended+: the end event is out (what follows is post-turn work).
1363
1363
  # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
1364
1364
  # set on the kernel in #generate.
1365
+ # +id+: names the turn on :turn_started (its metrics record's id) and on
1366
+ # its saved prompt, so a UI pairs the two whatever left the conversation.
1365
1367
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1366
- :settings) do
1368
+ :settings, :id) do
1367
1369
  # The turn's boundary events carry the origin only when there is one,
1368
1370
  # so payloads stay unchanged for callers that don't pass it.
1369
1371
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1478,7 +1480,8 @@ module Samagotchi
1478
1480
  @guardrail_wiring.begin_turn(origin)
1479
1481
 
1480
1482
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1481
- controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
1483
+ controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1484
+ id: SecureRandom.uuid)
1482
1485
  end
1483
1486
  private :begin_turn
1484
1487
 
@@ -1492,7 +1495,7 @@ module Samagotchi
1492
1495
  image_refs, image_error = turn_image_refs(session, images)
1493
1496
  # Emit turn_started event
1494
1497
  bind_metrics(session)
1495
- turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
1498
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1496
1499
  turn_started[:continue] = true if turn.continue
1497
1500
  turn_started[:images] = image_refs unless image_refs.empty?
1498
1501
  emit_event(turn.on_event, turn.tag(turn_started))
@@ -1565,7 +1568,8 @@ module Samagotchi
1565
1568
  thinking_target = @host_registry.resolve(@effective_model_name)
1566
1569
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1567
1570
  announce_thinking_level(*@turn_thinking)
1568
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
1571
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1572
+ window_setting: nil)
1569
1573
  end
1570
1574
  private :turn_settings
1571
1575
 
@@ -1594,7 +1598,7 @@ module Samagotchi
1594
1598
 
1595
1599
  return if turn.continue
1596
1600
 
1597
- user_message = { role: "user", content: turn.prompt }
1601
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
1598
1602
  user_message[:images] = image_refs unless image_refs.empty?
1599
1603
  turn.messages << user_message
1600
1604
  session.last_prompt = turn.prompt
@@ -1610,7 +1614,7 @@ module Samagotchi
1610
1614
  # The turn's settings, set once here. The chat loop dispatches tools
1611
1615
  # through the kernel without its #run: tag those dumps with this
1612
1616
  # turn's model, not the last native one.
1613
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
1617
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
1614
1618
 
1615
1619
  backend.complete(
1616
1620
  messages: turn.messages,
@@ -2244,7 +2248,17 @@ module Samagotchi
2244
2248
  adapter = @host_registry.adapter_for(target.entry)
2245
2249
  client = nil if adapter.remote?
2246
2250
  end
2247
- ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
2251
+ ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
2252
+ setting: ContextWindow.setting(target, names: model_lookup_names(target)))
2253
+ rescue StandardError
2254
+ nil
2255
+ end
2256
+
2257
+ # The effective model's models:/hosts: window_tokens, read per turn
2258
+ # (after the hooks that may switch the model).
2259
+ def turn_window_setting
2260
+ target = @host_registry.resolve(@effective_model_name)
2261
+ ContextWindow.setting(target, names: model_lookup_names(target))
2248
2262
  rescue StandardError
2249
2263
  nil
2250
2264
  end
@@ -33,8 +33,11 @@ module Samagotchi
33
33
  # chi started there answers as a parent (#parent_process?).
34
34
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
35
35
  # Environment variables that say an agent runs this process: Claude
36
- # Code's, and chi's own. They survive a PTY wrapper (script, expect).
37
- AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", PARENT_SESSION_ENV].freeze
36
+ # Code's, the AI_AGENT convention, Codex CLI's (CODEX_THREAD_ID, which
37
+ # Codex sets on every command its shell tool runs), and chi's own.
38
+ # They survive a PTY wrapper (script, expect). The docs list them too
39
+ # (guardrails.md, sub-agent.md).
40
+ AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", "CODEX_THREAD_ID", PARENT_SESSION_ENV].freeze
38
41
  # Rules that ask about chi's own config and hooks: the core
39
42
  # ProtectedPaths asks and the guardrails bundle's shell rule.
40
43
  PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require_relative "config"
4
5
  require_relative "model_ref"
5
6
  require_relative "client"
@@ -31,9 +32,12 @@ module Samagotchi
31
32
  # first_token_timeout: the configured first-token limit (see #first_token_limit);
32
33
  # vision: the configured true/false (VisionSupport), nil when unset;
33
34
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
34
- # thinking: the configured level (Thinking), nil when unset.
35
+ # thinking: the configured level (Thinking), nil when unset;
36
+ # remote: the configured true/false (#remote?), nil when unset;
37
+ # window_tokens: the configured context window (ContextWindow.setting), nil when unset.
35
38
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
36
- :first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
39
+ :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
40
+ keyword_init: true) do
37
41
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
38
42
  # raw-prompt loop.
39
43
  def chat? = api == :openai
@@ -45,9 +49,12 @@ module Samagotchi
45
49
  # configured, else the root's /v1.
46
50
  def openai_base_url = url || "#{root_url}/v1"
47
51
 
48
- # A provider on the network rather than a local server: it needs a key
49
- # or speaks https. Its model list is cached longer.
50
- def remote? = !api_key_env.to_s.empty? || scheme == "https"
52
+ # A provider on the network rather than a local server: hosts.<name>.remote
53
+ # when set, else by its address (HostRegistry.remote_address?). An API
54
+ # key doesn't decide: a llama.cpp on the LAN can have one. A remote
55
+ # host's model list is cached longer, it gets a first-token limit, and
56
+ # it isn't asked for llama.cpp's /props.
57
+ def remote? = remote.nil? ? HostRegistry.remote_address?(scheme, host) : remote
51
58
 
52
59
  def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
53
60
 
@@ -89,7 +96,8 @@ module Samagotchi
89
96
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
90
97
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
91
98
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
92
- vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
99
+ vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
100
+ remote: cfg[:remote], window_tokens: cfg[:window_tokens])
93
101
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
94
102
  first_token_timeout: entry.first_token_limit, name: entry.name,
95
103
  api_key_env: entry.api_key_env, env: env)
@@ -113,6 +121,23 @@ module Samagotchi
113
121
  @entries
114
122
  end
115
123
 
124
+ # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
125
+ LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
126
+ ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
127
+
128
+ # Whether an address is remote: https, or an http IP address outside
129
+ # the local nets (LOCAL_NETS). An http host named by a name (localhost,
130
+ # box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
131
+ def self.remote_address?(scheme, host)
132
+ return true if scheme.to_s == "https"
133
+
134
+ address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
135
+ address = address.native if address.ipv4_mapped?
136
+ LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
137
+ rescue IPAddr::Error
138
+ false
139
+ end
140
+
116
141
  # server.first_token_timeout, or nil when unset or unreadable.
117
142
  def self.configured_first_token_timeout
118
143
  Config.get("server.first_token_timeout")