samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -1,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "cache_confirm"
3
+ require_relative "level_setting_command"
4
4
  require_relative "reasoning_history"
5
5
 
6
6
  module Samagotchi
7
7
  # /reasoning-history, the session's own reasoning_history level
8
- # (Session#reasoning_history): with no argument, the level the next turn
9
- # runs under and where it came from; `/reasoning-history drop|turn|all`
10
- # sets the session's own (an explicit turn too), `/reasoning-history
11
- # default` unsets it (the session follows SAMAGOTCHI_REASONING_HISTORY,
12
- # the model, its host, then turn). A change takes effect at the next
13
- # turn's start; a native host ignores the level (its loop keeps the
14
- # running turn's reasoning), but the session keeps it for a chat model.
15
- # A change that rewrites the earlier replies asks first (CacheConfirm);
16
- # --yes (or -y) applies it.
17
- class ReasoningHistoryCommand
8
+ # (Session#reasoning_history): `/reasoning-history drop|turn|all` sets it
9
+ # (an explicit turn too), `/reasoning-history default` unsets it (the
10
+ # session follows SAMAGOTCHI_REASONING_HISTORY, the model, its host, then
11
+ # turn). Resolved on any host: a native one ignores the level (its loop
12
+ # keeps the running turn's reasoning), but the session keeps it for a
13
+ # chat model. A change that rewrites the earlier replies asks first. See
14
+ # LevelSettingCommand.
15
+ class ReasoningHistoryCommand < LevelSettingCommand
18
16
  NAME = "/reasoning-history"
19
17
  USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
20
18
  NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
@@ -24,84 +22,25 @@ module Samagotchi
24
22
  "from the first earlier thought."
25
23
  }.freeze
26
24
 
27
- # @param engine [Engine] #reasoning_history_explained,
28
- # #reasoning_history_override(=), #session, #chat_model?,
29
- # #reasoning_history_cache_cost, #history?
30
- # @param save [#call] saves the session after a change
31
- def initialize(engine:, save:)
32
- @engine = engine
33
- @save = save
34
- end
35
-
36
- # @param args [String] what follows /reasoning-history (--yes anywhere)
37
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
- # false applies it at once
39
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
- # whether the level changed, and the confirm it asks for (nothing
41
- # changed then)
42
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
43
- def run(args, confirm: true)
44
- word, yes = CacheConfirm.split(args)
45
- return [listing(explained), false, nil] if word.empty?
46
-
47
- level = ReasoningHistory.session_level(word)
48
- raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
49
-
50
- session = @engine.session or raise ArgumentError, "no session yet"
51
- before = explained
52
- ask = confirm && !yes ? ask(before, level, word) : nil
53
- return ask if ask
54
-
55
- @engine.reasoning_history_override = level
56
- @save.call(session)
57
- after = explained
58
- [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
- end
60
-
61
- # The level, where it came from, and what the session sets itself.
62
- def listing(explained)
63
- return "reasoning history: can't be resolved for this model" unless explained
64
-
65
- lines = ["reasoning history: #{explained.label}"]
66
- unless explained.own
67
- lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
68
- end
69
- lines << NATIVE_NOTE unless chat?
70
- lines.join("\n")
71
- end
72
-
73
- private
74
-
75
- # Resolved on any host: a native one ignores it, but /reasoning-history
76
- # still shows what a chat model would get.
77
- def explained(own: @engine.reasoning_history_override)
78
- @engine.reasoning_history_explained(native: true, own: own)
79
- end
80
-
81
- # The reply that asks before a costly change, nil when it needn't.
82
- def ask(before, own, word)
83
- would = explained(own: own)
84
- return nil unless before && would
85
-
86
- cost = @engine.reasoning_history_cache_cost(before.level, would.level)
87
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
88
-
89
- step = CacheConfirm.confirm(NAME, word)
90
- [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
91
- end
92
-
93
- def chat?
94
- @engine.chat_model?
95
- rescue StandardError
96
- false
97
- end
98
-
99
- def notes(before, after)
100
- return [] unless before && after
101
- return ["(no change to what the next turn runs under)"] if before.level == after.level
102
-
103
- cost = @engine.reasoning_history_cache_cost(before.level, after.level)
104
- ["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
105
- end
25
+ SPEC = Spec.new(
26
+ name: NAME, label: "reasoning history", usage: USAGE, cost_notes: COST_NOTES,
27
+ parse: lambda { |word|
28
+ level = ReasoningHistory.session_level(word)
29
+ [!level.nil? || word == ReasoningHistory::UNSET, level]
30
+ },
31
+ own: lambda(&:reasoning_history_override),
32
+ explained: ->(engine, own) { engine.reasoning_history_explained(native: true, own: own) },
33
+ set: ->(engine, own) { engine.reasoning_history_override = own },
34
+ cache_cost: ->(engine, from, to) { engine.reasoning_history_cache_cost(from, to) },
35
+ follows: "#{ReasoningHistory::ENV_NAME}, the model, its host, then turn",
36
+ extra_line: lambda { |engine|
37
+ chat = begin
38
+ engine.chat_model?
39
+ rescue StandardError
40
+ false
41
+ end
42
+ chat ? nil : NATIVE_NOTE
43
+ }
44
+ )
106
45
  end
107
46
  end
@@ -26,7 +26,7 @@ module Samagotchi
26
26
 
27
27
  module_function
28
28
 
29
- # Runs +fn+ (the tool's refs: lambda, as Api.per_call wraps it: call →
29
+ # Runs +fn+ (the tool's refs: lambda, as ToolSpec#per_call wraps it: call →
30
30
  # a list of {kind:, target:, label:, actions:}) on +call+. Never
31
31
  # raises: a lambda that raises or returns something other than a list
32
32
  # gives none, and a bad ref is dropped; both are logged (warn, not
@@ -13,6 +13,7 @@ require_relative "thinking"
13
13
  require_relative "reasoning_history"
14
14
  require_relative "served_model"
15
15
  require_relative "host_registry"
16
+ require_relative "host_keys"
16
17
  require_relative "llm/adapters"
17
18
  require_relative "tools/memory"
18
19
  require_relative "hooks/loader"
@@ -391,13 +392,14 @@ module Samagotchi
391
392
  "#{entry.name} #{location}#{suffix}"
392
393
  end
393
394
 
394
- # The variable the host's API key comes from and whether it is set; the
395
- # key itself is never shown.
395
+ # The variable the host's API key comes from and whether it is set, or
396
+ # comes from the Keychain (HostKeys); the key itself is never shown.
396
397
  def api_key_for(model, env)
397
398
  name = HostRegistry.new(env: env).resolve(model).entry&.api_key_env
398
399
  return "-" unless name
399
400
 
400
- "#{name} (#{env[name].to_s.empty? ? "unset" : "set"})"
401
+ state = { env: "set", keychain: "keychain" }.fetch(HostKeys.source(name, env: env), "unset")
402
+ "#{name} (#{state})"
401
403
  end
402
404
 
403
405
  # The Chi Helper app's version against this chi's (read from its
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  USAGE = <<~TEXT
41
41
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
42
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
43
- chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
43
+ chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
45
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
46
46
  chi send --wait [--timeout S] [--format json] ID
@@ -63,9 +63,11 @@ module Samagotchi
63
63
  the current one
64
64
  --continues ID
65
65
  (--new) start the next link of that session's chain:
66
- in its folder, on its model, LLM context,
67
- thinking level and toolkit, with a note carrying its recap; it
68
- is archived. last:ID names the chain's latest link.
66
+ in its folder, on its model (or --model's; one whose
67
+ host is gone falls back to the default, with a
68
+ warning), LLM context, thinking level and toolkit,
69
+ with a note carrying its recap; it is archived.
70
+ last:ID names the chain's latest link.
69
71
  Without -m the new session waits idle. Refused for a
70
72
  session continued already (naming the next link) or
71
73
  with delegates still open
@@ -289,11 +291,11 @@ module Samagotchi
289
291
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
290
292
 
291
293
  if options[:continues]
292
- taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
294
+ taken = [("--dir" if options[:dir])] +
293
295
  LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
294
296
  [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
295
297
  unless taken.compact.empty?
296
- return usage_error("--continues takes the previous session's folder, model, LLM context, thinking level and " \
298
+ return usage_error("--continues takes the previous session's folder, LLM context, thinking level and " \
297
299
  "toolkit; " \
298
300
  "leave out #{taken.compact.join(", ")}")
299
301
  end
@@ -358,7 +360,8 @@ module Samagotchi
358
360
  begin
359
361
  start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
360
362
  session = if options[:continues]
361
- SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
363
+ SessionManager.continue_session(options[:continues], **start, model: options[:model],
364
+ state_dir: @state_dir)
362
365
  else
363
366
  SessionManager.spawn_session(**start, working_directory: options[:dir],
364
367
  model_name: options[:model],
@@ -388,15 +391,22 @@ module Samagotchi
388
391
  timeout: options[:timeout])
389
392
  end
390
393
 
391
- # --continues: the model the new link will run (the previous link's),
392
- # for the images check; [nil, nil] without --continues.
394
+ # The model the new session will run, for the images check: --model
395
+ # (nil: the default), or with --continues the one the new link will run
396
+ # (SessionContinue.model_for: --model's, else the previous link's, else
397
+ # the default it falls back to).
393
398
  # @return [Array, false] [model_name, model_typed], or false after the
394
- # error line (no such session)
399
+ # error line (no such session, a model it can't start on)
395
400
  def continued_model(options)
396
401
  return [options[:model], nil] unless options[:continues]
397
402
 
398
403
  previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
399
- [previous.model_name, previous.model_typed]
404
+ choice = SessionContinue.model_for(previous, options[:model])
405
+ inherited = options[:model].to_s.strip.empty? && choice.warning.nil?
406
+ inherited ? [previous.model_name, previous.model_typed] : [choice.model, nil]
407
+ rescue ModelProfile::MissingModel => e
408
+ error_line("chi send: could not start a session: #{e.message}")
409
+ false
400
410
  rescue ArgumentError => e
401
411
  error_line("chi send: #{e.message}")
402
412
  false
@@ -259,6 +259,8 @@ module Samagotchi
259
259
  self.interrupted_turn = interrupted_turn
260
260
  @turn_checkpoint = nil
261
261
  @archived = false
262
+ # #save's lock: one snapshot-and-write at a time (see #save).
263
+ @save_lock = Mutex.new
262
264
  end
263
265
 
264
266
  # A `delegate` child: its turns report to its parent (ChildReports). A
@@ -513,18 +515,30 @@ module Samagotchi
513
515
  end
514
516
 
515
517
  # Persist the session atomically. Updates +updated_at+ in place.
518
+ #
519
+ # One save at a time per Session: a worker saves its session from the
520
+ # loop/turn thread (TurnCheckpoint, Engine's turn end, WorkerInbound),
521
+ # from Bridge threads (QuestionDesk, commands run mid-turn) and from a
522
+ # signal's thread. AtomicFile keeps each write whole, but two
523
+ # unserialized saves could rename an older snapshot over a newer one (a
524
+ # lost update); under the lock the last rename is the latest snapshot.
525
+ # Never call it from a trap handler (Mutex#lock raises ThreadError
526
+ # there; WorkerSignals saves on a thread of its own) or from inside a
527
+ # save (the lock isn't re-entrant).
516
528
  def save(state_dir: self.class.default_state_dir)
517
- @updated_at = Time.now.iso8601(3)
518
- # Persist with current metadata version so new flag is written
519
- @metadata_version = METADATA_VERSION
520
- FileUtils.mkdir_p(state_dir)
529
+ @save_lock.synchronize do
530
+ @updated_at = Time.now.iso8601(3)
531
+ # Persist with current metadata version so new flag is written
532
+ @metadata_version = METADATA_VERSION
533
+ FileUtils.mkdir_p(state_dir)
521
534
 
522
- path = self.class.session_file(@id, state_dir: state_dir)
535
+ path = self.class.session_file(@id, state_dir: state_dir)
523
536
 
524
- # Auto-compute first_preview if not yet cached and messages contain a user entry.
525
- compute_first_preview!
537
+ # Auto-compute first_preview if not yet cached and messages contain a user entry.
538
+ compute_first_preview!
526
539
 
527
- AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
540
+ AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
541
+ end
528
542
  self
529
543
  end
530
544
 
@@ -56,11 +56,21 @@ module Samagotchi
56
56
  # moved the chain's end while it waited for the lock.
57
57
  TRIES = 3
58
58
 
59
+ # The model a new link runs on (#model_for): the name to spawn it with,
60
+ # and why it isn't the previous link's (nil when it is, or was given).
61
+ ModelChoice = Data.define(:model, :warning)
62
+ # What #check! decided before anything changes: the delegates to move
63
+ # (ChildMove::Plan) and the new link's model (ModelChoice).
64
+ Checked = Data.define(:plan, :choice)
65
+
59
66
  # Start the next link of a chain: a new session that continues
60
67
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
61
68
  # it was typed as, so an alias holds) and with its own setup (SessionSetup),
62
69
  # starting with a context note from chi: the link and the previous
63
- # link's recap (SessionChain.note_text). The previous link is archived
70
+ # link's recap (SessionChain.note_text). +model+, when given, is the
71
+ # new link's model instead; an inherited one whose host can't run it any
72
+ # more (gone, disabled, an alias naming another host) falls back to the
73
+ # default with a warning (#model_for). The previous link is archived
64
74
  # first, with its finished delegates (archive_session's rules: a turn
65
75
  # running, a prompt queued or the step-limit question refuse; an idle
66
76
  # worker is stopped), so a refusal starts nothing. Its open delegates
@@ -81,7 +91,7 @@ module Samagotchi
81
91
  # @raise [Refused] continued already, delegates open that can't
82
92
  # move, or its folder is gone
83
93
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
84
- def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
94
+ def self.run(id_or_ref, prompt: nil, title: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
85
95
  sd = state_dir || Session.default_state_dir
86
96
  TRIES.times do |try|
87
97
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
@@ -89,7 +99,7 @@ module Samagotchi
89
99
  follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
90
100
  next :moved if follow && SessionChain.next_of(id, state_dir: sd)
91
101
 
92
- run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
102
+ run_locked(id, prompt: prompt, title: title, model: model, state_dir: sd, recap_wait: recap_wait,
93
103
  archive_wait: archive_wait)
94
104
  end
95
105
  return result unless result == :moved
@@ -105,9 +115,10 @@ module Samagotchi
105
115
  end
106
116
  end
107
117
 
108
- private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
118
+ private_class_method def self.run_locked(id, prompt:, title:, model:, state_dir:, recap_wait:, archive_wait:)
109
119
  previous = Session.load(id, state_dir: state_dir)
110
- plan = check!(previous, state_dir)
120
+ checked = check!(previous, state_dir, model: model)
121
+ plan = checked.plan
111
122
  # archive_session's refusals that can be known now, before the wait.
112
123
  SessionManager.archivable!(id, state_dir, except: plan.move)
113
124
  recap_before_archive(id, state_dir, wait: recap_wait)
@@ -120,7 +131,8 @@ module Samagotchi
120
131
  archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
121
132
  archived = archive[:archived] - already
122
133
  begin
123
- spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
134
+ spawn_continuation(previous, prompt: prompt, title: title, choice: checked.choice, state_dir: state_dir,
135
+ move: plan.move)
124
136
  rescue StandardError
125
137
  # Nothing started: the delegates go back, what this archived goes
126
138
  # back to the lists, and a new link saved before its worker failed
@@ -181,7 +193,7 @@ module Samagotchi
181
193
 
182
194
  # The new link, holding off any worker but its own (ChildMove.starting?)
183
195
  # while +move+ goes to it.
184
- private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
196
+ private_class_method def self.spawn_continuation(previous, prompt:, title:, choice:, state_dir:, move:)
185
197
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
186
198
  start = prompt.to_s.strip.empty? ? nil : prompt
187
199
  title = previous.first_preview if title.to_s.strip.empty? && start.nil?
@@ -191,33 +203,64 @@ module Samagotchi
191
203
  end
192
204
  link = SessionManager.spawn_session(
193
205
  prompt: start, title: title, working_directory: previous.working_directory,
194
- model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
206
+ model_name: choice.model, setup: SessionSetup.of(previous),
195
207
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
196
208
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
197
209
  )
198
210
  link.moved_children = move
211
+ # The fallback's warning first: spawn_session set its own.
212
+ warnings = [choice.warning, link.model_warning].compact
213
+ link.model_warning = warnings.join("; ") unless warnings.empty?
199
214
  link
200
215
  end
201
216
 
217
+ # The model a new link of +previous+'s chain runs on: +given+ when
218
+ # there is one (its host checked: ModelProfile::UnknownHost as for a new
219
+ # session), else the previous link's, as typed (so an alias holds). An
220
+ # inherited one whose host can't run it any more (gone from config.yml,
221
+ # disabled, an alias naming another host: UnknownHost) falls back to the
222
+ # default, with a warning naming why. `chi send --new --continues`
223
+ # judges images by it before #run decides the same.
224
+ # @param previous [Session]
225
+ # @param given [String, nil] blank: inherit
226
+ # @return [ModelChoice]
227
+ # @raise [ModelProfile::MissingModel] a +given+ model it can't run, or
228
+ # a fallback with no default configured (or the default's host can't run it)
229
+ def self.model_for(previous, given)
230
+ unless given.to_s.strip.empty?
231
+ return ModelChoice.new(model: ModelProfile.check_host!(ModelProfile.required_model_name(given.strip)),
232
+ warning: nil)
233
+ end
234
+
235
+ inherited = ModelProfile.required_model_name(previous.model_typed || previous.model_name)
236
+ begin
237
+ ModelChoice.new(model: ModelProfile.check_host!(inherited), warning: nil)
238
+ rescue ModelProfile::UnknownHost => e
239
+ default = ModelProfile.check_host!(ModelProfile.required_model_name(nil))
240
+ ModelChoice.new(model: default, warning: "#{inherited} can't run (#{e.message}); started on #{default}")
241
+ end
242
+ end
243
+
202
244
  # The refusals that come before anything changes: continued already (no
203
245
  # forks in a chain), an open delegate that can't move to the new link
204
246
  # (ChildMove.plan), the folder gone (the worker would run elsewhere), a
205
- # model whose host config.yml no longer has.
206
- # @return [ChildMove::Plan]
247
+ # given model whose host config.yml doesn't have, or an inherited one
248
+ # that can't run with no default to fall back to (#model_for).
249
+ # @return [Checked]
207
250
  # @raise [Refused, ModelProfile::MissingModel]
208
- private_class_method def self.check!(previous, state_dir)
251
+ private_class_method def self.check!(previous, state_dir, model: nil)
209
252
  if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
210
253
  raise Refused.new(previous.id, :continued, ids: [following])
211
254
  end
212
255
 
213
256
  plan = move_plan!(previous.id, state_dir)
214
- # Its model's host, as SessionManager.spawn_session checks it: before
215
- # the archive stops the previous link's worker.
216
- ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
257
+ # The new link's model, its host checked as SessionManager.spawn_session
258
+ # checks it: before the archive stops the previous link's worker.
259
+ choice = model_for(previous, model)
217
260
  dir = previous.working_directory.to_s
218
261
  raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
219
262
 
220
- plan
263
+ Checked.new(plan: plan, choice: choice)
221
264
  end
222
265
 
223
266
  # A child still open below a session (#open_children); why: running,
@@ -236,15 +236,6 @@ module Samagotchi
236
236
  UNREADABLE_INPUT
237
237
  end
238
238
 
239
- # Whether an unclaimed input file carries images (a mid-turn drain
240
- # leaves it for its own turn).
241
- def self.input_has_images?(input_file)
242
- data = JSON.parse(File.read(input_file))
243
- data.is_a?(Hash) && Array(data["images"]).any?
244
- rescue JSON::ParserError, SystemCallError
245
- false
246
- end
247
-
248
239
  # Whether a mid-turn drain leaves this unclaimed input file for a turn
249
240
  # of its own instead of merging it at a step boundary: a queued message
250
241
  # (`delivery: queue`) or an image message (steering merges text only).
@@ -258,10 +249,6 @@ module Samagotchi
258
249
  false
259
250
  end
260
251
 
261
- # Whether an input file carrying +enqueued_id+ still waits unclaimed in
262
- # input/ (no worker has taken it for a turn or a step yet).
263
- def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
264
-
265
252
  # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
266
253
  # (a queue or an image message: it runs as a turn of its own, never
267
254
  # merged, #waits_for_turn_end?), :merges (a running turn's drain may
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effective_model"
4
+
5
+ module Samagotchi
6
+ # One per-session setting (the session's own value, before its model's):
7
+ # what the UIs show as a chip and the commands set. +key+ is the Session
8
+ # attribute and the chip's key everywhere (the session state, /stats, a
9
+ # :command_ran, the web's session JSON); +changed_by+ the commands'
10
+ # `changed` entries that move the chip; +snapshot_fallback+ how the web
11
+ # treats a worker snapshot's value: :or (a nil value falls back to the
12
+ # session file's) or :key (a key present stands, nil included: a worker
13
+ # says nil on a native host, which ignores reasoning_history; an older
14
+ # worker has no key). +chip+ the chip from an EffectiveModel and the
15
+ # session's own value; nil for the toolkit, which resolves against the
16
+ # worker's kits (Engine#toolkit_summary), not the model. +live+ the
17
+ # chip as an Engine's public readers give it (a :command_ran's).
18
+ SessionSetting = Data.define(:key, :changed_by, :snapshot_fallback, :chip, :live) do
19
+ # The session's own value, nil without a session or one.
20
+ def own(session) = session&.public_send(key)
21
+
22
+ # Sets the session's own value (nil unsets it); the caller saves.
23
+ def set(session, value) = session.public_send(:"#{key}=", value)
24
+
25
+ # Whether a command that changed +changed+ moves this chip.
26
+ def changed_by?(changed) = changed.intersect?(changed_by)
27
+
28
+ # Whether the chip comes from the model (not the toolkit).
29
+ def model? = !chip.nil?
30
+
31
+ # The chip for +session+ on +model+ (EffectiveModel, nil when it
32
+ # doesn't resolve: nil then).
33
+ def chip_for(model, session) = model && chip.call(model, own(session))
34
+
35
+ # The chip as +engine+ (a worker's) gives it now.
36
+ def summary(engine) = live.call(engine)
37
+
38
+ # The chip for a saved session no worker runs (Web::App, the session
39
+ # lists): resolved from its file, config and the registry (no network);
40
+ # nil when it can't be.
41
+ def saved_summary(session, registry:)
42
+ return saved_toolkit(session.toolkit) unless model?
43
+
44
+ chip_for(EffectiveModel.of_session(session, registry: registry), session)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ # The web's chip: a worker's snapshot (string keys) per
50
+ # +snapshot_fallback+, else the block's (the saved one).
51
+ def snapshot_value(snapshot)
52
+ name = key.to_s
53
+ return snapshot[name] if snapshot_fallback == :key && snapshot&.key?(name)
54
+ return snapshot[name] if snapshot_fallback == :or && snapshot && snapshot[name]
55
+
56
+ yield
57
+ end
58
+
59
+ private
60
+
61
+ # A toolkit as its file names it ({name:, label:}), nil on full:
62
+ # without a worker nothing resolves it (a bundle's kits load there).
63
+ def saved_toolkit(toolkit)
64
+ toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
65
+ end
66
+ end
67
+
68
+ class SessionSetting
69
+ LLM_CONTEXT = new(key: :llm_context, changed_by: %i[llm_context model], snapshot_fallback: :or,
70
+ chip: ->(model, own) { model.llm_context_explained(own&.resolve_args || {})&.summary(own) },
71
+ live: lambda(&:llm_context_summary))
72
+ THINKING = new(key: :thinking, changed_by: %i[thinking model], snapshot_fallback: :or,
73
+ chip: ->(model, own) { model.thinking_explained(own: own)&.summary },
74
+ live: ->(engine) { engine.thinking_explained&.summary })
75
+ # nil on a native host (its loop ignores the level).
76
+ REASONING_HISTORY = new(key: :reasoning_history, changed_by: %i[reasoning_history model], snapshot_fallback: :key,
77
+ chip: ->(model, own) { model.reasoning_history_explained(own: own)&.summary },
78
+ live: ->(engine) { engine.reasoning_history_explained&.summary })
79
+ TOOLKIT = new(key: :toolkit, changed_by: %i[toolkit], snapshot_fallback: :key, chip: nil, live: lambda(&:toolkit_summary))
80
+
81
+ # In the order the session state, a :command_ran and the web name them.
82
+ ALL = [LLM_CONTEXT, THINKING, REASONING_HISTORY, TOOLKIT].freeze
83
+ # /stats's (no toolkit: its JSON never had one).
84
+ STATS = [LLM_CONTEXT, THINKING, REASONING_HISTORY].freeze
85
+ end
86
+ end