samagotchi 0.155.0 → 0.156.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. metadata +13 -1
@@ -14,78 +14,62 @@ module Samagotchi
14
14
  # audit of what comes in at a turn's start and what goes out at its end
15
15
  # (ObjectivesAudit), the view announced when it changed, and the model's
16
16
  # proposals kept as cards until the turn is over. Its state is those
17
- # proposals under their mutex, the last view announced and the audit.
17
+ # proposals under their mutex and the last view announced (under the
18
+ # event lock); the audit is built per call.
18
19
  class Objectives
19
20
  # @param side_judgments [SideJudgments] the Engine's side asks (the audit's kinds)
20
21
  # @param host_registry [HostRegistry] System 1's client resolves its host here
21
- # @param state_dir [#call] the sessions dir
22
- # @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
23
- # @param runs [#call] the Engine's SystemOne::Runs
24
- # @param synchronize [#call] (&block) runs the block under the event lock (Engine#synchronize_events)
25
- # @param announce [#call] (event) tells the observers (Engine#announce)
26
- # @param running [#call] the running turn's conversation, nil between turns (Engine#live_conversation)
27
- # @param show_card [#call] (**card) shows a card (Engine#show_card)
28
- def initialize(side_judgments:, host_registry:, state_dir:, recording:, runs:, synchronize:, announce:, running:, show_card:)
22
+ def initialize(side_judgments:, host_registry:)
29
23
  @side_judgments = side_judgments
30
- @state_dir = state_dir
31
- @synchronize = synchronize
32
- @announce = announce
33
- @running = running
34
- @show_card = show_card
24
+ @host_registry = host_registry
35
25
  # The last view announced (#announce), under the event lock.
36
26
  @announced = nil
37
27
  # The model's proposals until the turn is over (#finally).
38
28
  @proposals = []
39
29
  @proposals_mutex = Mutex.new
40
- # With saving off nothing would come of an ask, so none is made.
41
- @audit = ObjectivesAudit.new(
42
- side: side_judgments, state_dir: state_dir,
43
- client_for: lambda do |judgment, session|
44
- next nil unless runs.call.on?
45
-
46
- recording.call(SystemOne::Client.from_config(host_registry: host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
47
- judgment, session, mode: nil)
48
- end
49
- )
50
30
  end
51
31
 
52
32
  # System 1 on the objectives, in audit (ObjectivesAudit), at a turn's
53
33
  # start: the owner's turns only, and for a message a person's: a
54
34
  # reminder's or a delegate's prompt isn't one.
55
- def incoming(turn)
35
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this call)
36
+ def incoming(turn, recorder:)
56
37
  return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
57
38
 
58
- @audit.incoming(turn.session, turn.prompt)
39
+ audit(turn.context.state_dir, recorder).incoming(turn.session, turn.prompt)
59
40
  end
60
41
 
61
42
  # The turn's answer, after the end event and before :after_turn (the
62
43
  # caller skips a canceled or exhausted turn): the owner's turns only.
63
- def outgoing(turn, result)
44
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this
45
+ # call: after a before_turn hook's model switch, the new model)
46
+ def outgoing(turn, result, recorder:)
64
47
  return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
65
48
 
66
- @audit.outgoing(turn.session, result.text)
49
+ audit(turn.context.state_dir, recorder).outgoing(turn.session, result.text)
67
50
  end
68
51
 
69
52
  # A note added between turns (Engine#add_context_note): incoming too.
70
- def note(session, note) = @audit.note(session, note)
53
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this call)
54
+ # @param state_dir [String] the sessions dir
55
+ def note(session, note, recorder:, state_dir:) = audit(state_dir, recorder).note(session, note)
71
56
 
72
57
  # The session's objectives as the page draws them (Objectives.view), when
73
58
  # that differs from the last announced: after an edit (its file written,
74
59
  # pending), once a list note is in, at a turn's end (its moves) and after
75
- # a rollback. A session with no list announces nothing. It only informs:
76
- # a failure is logged, never the turn's.
77
- def announce(session)
78
- return unless session
79
-
80
- @synchronize.call do
81
- view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: @state_dir.call, running: @running.call&.dup)
82
- next if view.nil? || view == @announced
83
-
84
- @announced = view
85
- @announce.call({ type: :objectives_updated, session_id: session.id, **view })
86
- end
87
- rescue StandardError => e
88
- Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
60
+ # a rollback. A session with no list announces nothing. The caller
61
+ # (Engine#announce_objectives) holds the event lock, the one guard of
62
+ # the last view, announces the event and logs a failure: it only
63
+ # informs, never fails a turn.
64
+ # @param state_dir [String] the sessions dir
65
+ # @param running [Array<Hash>, nil] the running turn's conversation (Engine#live_conversation), nil between turns
66
+ # @return [Hash, nil] the :objectives_updated event to announce, nil when the view is the same
67
+ def announce(session, state_dir:, running:)
68
+ view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: state_dir, running: running&.dup)
69
+ return if view.nil? || view == @announced
70
+
71
+ @announced = view
72
+ { type: :objectives_updated, session_id: session.id, **view }
89
73
  end
90
74
 
91
75
  # A change the model proposes (the objectives tool), kept until the turn
@@ -108,9 +92,15 @@ module Samagotchi
108
92
  # failed or rolled-back turn keeps none, and its proposals are dropped
109
93
  # here so the next turn does not show them either: their Apply card
110
94
  # would sit after a turn whose `objectives propose` call is not in the
111
- # history (a rollback after Ctrl-C drops it).
95
+ # history (a rollback after Ctrl-C drops it). The caller
96
+ # (Engine#show_objective_proposals) shows the cards.
97
+ # @return [Array<Hash>] the completed turn's cards (Engine#show_card's
98
+ # arguments); none for any other ending
112
99
  def finally(turn)
113
- turn.completed? ? show_proposals : discard_proposals(turn)
100
+ return proposal_cards if turn.completed?
101
+
102
+ discard_proposals(turn)
103
+ []
114
104
  end
115
105
 
116
106
  # For specs: the audit's asks' thread for +kind+ (ObjectivesAudit's
@@ -122,15 +112,13 @@ module Samagotchi
122
112
 
123
113
  private
124
114
 
125
- def show_proposals
126
- drain_proposals.each do |proposal|
115
+ def proposal_cards
116
+ drain_proposals.map do |proposal|
127
117
  id = "objectives-#{SecureRandom.hex(4)}"
128
- @show_card.call(source: Samagotchi::Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
129
- actions: [{ label: "Apply", command: proposal.action_line(id) },
130
- { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
118
+ { source: Samagotchi::Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
119
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
120
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }] }
131
121
  end
132
- rescue StandardError => e
133
- Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
134
122
  end
135
123
 
136
124
  def discard_proposals(turn)
@@ -141,6 +129,22 @@ module Samagotchi
141
129
  def drain_proposals
142
130
  @proposals_mutex.synchronize { @proposals.slice!(0..) }
143
131
  end
132
+
133
+ # The audit for one call, over the values it is given: nothing of it
134
+ # is kept. Its client is asked for only once an active objective is
135
+ # there (ObjectivesAudit#ask), and with saving off nothing would come
136
+ # of an ask, so none is made.
137
+ def audit(state_dir, recorder)
138
+ ObjectivesAudit.new(
139
+ side: @side_judgments, state_dir: state_dir,
140
+ client_for: lambda do |judgment, session|
141
+ next nil unless recorder.saving?
142
+
143
+ recorder.wrap(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
144
+ judgment, session, mode: nil)
145
+ end
146
+ )
147
+ end
144
148
  end
145
149
  end
146
150
  end
@@ -23,17 +23,11 @@ module Samagotchi
23
23
  # @param host_registry [HostRegistry] System 1's client resolves its host here
24
24
  # @param toolkit_binding [ToolkitBinding] the kits, #suggest and #fell_back
25
25
  # @param tools [Tools::Registry] the registered tools, for the snapshot
26
- # @param state_dir [#call] the sessions dir (the suggestion log's place)
27
- # @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
28
- # @param runs [#call] the Engine's SystemOne::Runs
29
- def initialize(side_judgments:, host_registry:, toolkit_binding:, tools:, state_dir:, recording:, runs:)
26
+ def initialize(side_judgments:, host_registry:, toolkit_binding:, tools:)
30
27
  @side_judgments = side_judgments
31
28
  @host_registry = host_registry
32
29
  @toolkit_binding = toolkit_binding
33
30
  @tools = tools
34
- @state_dir = state_dir
35
- @recording = recording
36
- @runs = runs
37
31
  @pending = nil
38
32
  @pending_turn = nil
39
33
  @warmup_thread = nil
@@ -52,12 +46,14 @@ module Samagotchi
52
46
  # snapshot. Audit never blocks: the thread logs its line. On mode
53
47
  # keeps the ask's Pending for +turn+, which #settle joins before
54
48
  # apply_toolkit!, with the turn's controller as the request's cancel
55
- # controller.
49
+ # controller. The suggestion log is in the turn's sessions dir
50
+ # (TurnContext#state_dir).
51
+ # @param recorder [SystemOne::Recorder] the ask's runs (built for this call)
56
52
  # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
57
- def start(turn)
53
+ def start(turn, recorder:)
58
54
  @pending = nil
59
55
  @pending_turn = turn
60
- @pending = ask(turn)
56
+ @pending = ask(turn, recorder)
61
57
  end
62
58
 
63
59
  # On mode, before apply_toolkit!: wait for +turn+'s ask (#start) up to
@@ -154,14 +150,14 @@ module Samagotchi
154
150
  # never reached #start) is never touched.
155
151
  def pending_for(turn) = (@pending if @pending_turn.equal?(turn))
156
152
 
157
- def ask(turn)
153
+ def ask(turn, recorder)
158
154
  mode = Config.get("toolkit.suggest.mode").to_s
159
155
  return if mode == "off"
160
156
 
161
157
  joined = mode == "on"
162
158
  session = turn.session
163
159
  default = Toolkit.of(session.toolkit)
164
- log = suggestion_log
160
+ log = suggestion_log(turn.context.state_dir)
165
161
  # The predicate checks the session first; the kits (memory kits are
166
162
  # read from disk) and the log (read whole) only when it gets there.
167
163
  kits = nil
@@ -185,8 +181,8 @@ module Samagotchi
185
181
  return
186
182
  end
187
183
 
188
- client = @recording.call(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
189
- ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
184
+ client = recorder.wrap(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
185
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
190
186
  return unless client
191
187
 
192
188
  # The warm-up's only value is the cache: the real ask never queues
@@ -215,7 +211,7 @@ module Samagotchi
215
211
  state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
216
212
  )
217
213
  threshold = Config.get("toolkit.suggest.threshold")
218
- runs = session.scratch ? nil : @runs.call
214
+ runs = session.scratch ? nil : recorder.runs
219
215
  pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
220
216
  acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
221
217
  timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
@@ -253,8 +249,8 @@ module Samagotchi
253
249
  end
254
250
  end
255
251
 
256
- def suggestion_log
257
- SideJudgments.log(ToolkitSuggest.log_path(@state_dir.call), tag: :toolkit, event: "toolkit_suggest_log_failed")
252
+ def suggestion_log(state_dir)
253
+ SideJudgments.log(ToolkitSuggest.log_path(state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
258
254
  end
259
255
  end
260
256
  end
@@ -17,21 +17,20 @@ module Samagotchi
17
17
  # against its source session as the turn begins; at its end (inside
18
18
  # Engine#end_turn's event step) the opening's stamp, a Revise turn's
19
19
  # draft checked and a Verify turn's verdict recorded. No state of its
20
- # own: it reads the turn, and the sessions it loads from the state dir.
20
+ # own: it reads the turn, and the sessions it loads from the state dir
21
+ # it is given.
21
22
  class VerifyAnchors
22
- # @param state_dir [#call] the sessions dir (a fresh Verify child's parent is loaded from it)
23
- def initialize(state_dir:)
24
- @state_dir = state_dir
25
- end
26
-
27
23
  # A Verify turn's claim (Engine#begin_turn), when its quote is in the
28
24
  # source session's answer for that turn; nil (logged) otherwise.
29
25
  # @param anchor [VerifyAnchor, nil]
26
+ # @param state_dir [String] the sessions dir (a fresh Verify child's
27
+ # parent is loaded from it): the Engine's at the call, as no
28
+ # TurnContext holds it yet
30
29
  # @return [VerifyAnchor, nil]
31
- def verified(session, anchor)
30
+ def verified(session, anchor, state_dir:)
32
31
  return unless anchor
33
32
 
34
- source = source_session(session, anchor)
33
+ source = source_session(session, anchor, state_dir)
35
34
  return unless source
36
35
 
37
36
  turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
@@ -133,14 +132,14 @@ module Samagotchi
133
132
  comparison_text(Redact.text(answer, viewer)).include?(quote_text)
134
133
  end
135
134
 
136
- def source_session(session, anchor)
135
+ def source_session(session, anchor, state_dir)
137
136
  return session unless session.verify_fresh
138
137
 
139
138
  fresh = session.verify_fresh
140
139
  return reject_verify(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
141
140
  return reject_verify(anchor, :fresh_parent_missing) unless session.parent_id
142
141
 
143
- Session.load(session.parent_id, state_dir: @state_dir.call)
142
+ Session.load(session.parent_id, state_dir: state_dir)
144
143
  rescue ArgumentError, SystemCallError, IOError => e
145
144
  Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
146
145
  error: e.class.name)
@@ -17,22 +17,17 @@ module Samagotchi
17
17
  # @param prompt_warmup [PromptWarmup] the Engine's warm-up (the kernel asks it for a slot pin)
18
18
  # @param kernel [KernelLoop] the warm-up prompt, the last slot, warmed_up!
19
19
  # @param turn_state [TurnState] whether steers are carried to the next turn
20
- # @param model_settings [SessionModelSettings] the next turn's LLM context strategy
21
- # @param model [#call] the effective model now (Engine#effective_model)
22
- # @param system_prompt [#call] (model) the system prompt (Engine#system_prompt)
23
- # @param reminders_due [#call] whether a reminder is due (Engine#reminders_due?)
24
- # @param next_turn_waiting [#call] whether the next turn starts at once or none follows
25
- # (Engine#next_turn_waiting, called)
26
- def initialize(prompt_warmup:, kernel:, turn_state:, model_settings:, model:, system_prompt:, reminders_due:,
27
- next_turn_waiting:)
20
+ # @param model_settings [SessionModelSettings] the effective model now, the next turn's LLM context strategy
21
+ # @param reminder_queue [ReminderQueue] whether a reminder is due
22
+ # @param system_prompt [#call] (model) the system prompt (Engine#system_prompt): called last, only
23
+ # once every check passed, as building it has side effects (the memory index metric, the prompt notes)
24
+ def initialize(prompt_warmup:, kernel:, turn_state:, model_settings:, reminder_queue:, system_prompt:)
28
25
  @prompt_warmup = prompt_warmup
29
26
  @kernel = kernel
30
27
  @turn_state = turn_state
31
28
  @model_settings = model_settings
32
- @model = model
29
+ @reminder_queue = reminder_queue
33
30
  @system_prompt = system_prompt
34
- @reminders_due = reminders_due
35
- @next_turn_waiting = next_turn_waiting
36
31
  end
37
32
 
38
33
  # After a completed turn, prefill the next turn's prompt up to its user
@@ -40,14 +35,18 @@ module Samagotchi
40
35
  # local llama.cpp host on the native loop, and not when the next turn's
41
36
  # start differs anyway or starts at once: a reminder due, a continue
42
37
  # offer (the turn ran out of steps), a carried steer or queued input.
43
- # Never fails the turn.
44
- def after(turn, result)
38
+ # Never fails the turn. Each read comes only once the checks before it
39
+ # passed: the model resolved after the turn's own outcome, the probe
40
+ # and the prompt only on a target.
41
+ # @param next_turn_waiting [#call, nil] the host's probe: true when the next turn starts at once or none
42
+ # follows (Engine#next_turn_waiting); nil: neither
43
+ def after(turn, result, next_turn_waiting:)
45
44
  return if result.canceled? || result.resumable?
46
45
 
47
- model = @model.call
46
+ model = @model_settings.effective_model
48
47
  client = model.client
49
48
  return unless target?(model, client)
50
- return if @reminders_due.call || @turn_state.carried_steers? || @next_turn_waiting.call
49
+ return if @reminder_queue.due? || @turn_state.carried_steers? || next_turn_waiting&.call
51
50
 
52
51
  messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: @system_prompt.call(model) })
53
52
  # Under the next turn's strategy (a /model switch may have changed it);
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.155.0"
4
+ VERSION = "0.156.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # @param target [HostRegistry::ModelTarget]
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
- # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
27
+ # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
28
28
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
29
29
  # @return [Answer]
30
30
  def self.for(target, names:, profile: nil, adapter: nil, models: nil)
@@ -47,11 +47,11 @@ module Samagotchi
47
47
 
48
48
  def self.configured(_target, entry, models, names)
49
49
  models ||= begin
50
- ConfigFile.model_settings
50
+ ModelSettings.all
51
51
  rescue StandardError
52
52
  {}
53
53
  end
54
- if (setting = ConfigFile.model_setting(names, :vision, models: models))
54
+ if (setting = ModelSettings.find(names, :vision, models: models))
55
55
  return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
56
56
  end
57
57
  return nil if entry.vision.nil?
@@ -353,10 +353,10 @@ module Samagotchi
353
353
  # "..", a %2F) is an unknown session before any handler makes a path.
354
354
  # (/api/sessions/resolve and /summaries take no id.)
355
355
  if req.path_info.start_with?("/api/sessions/") && !m.captures.empty? && !Session.valid_id?(m.captures.first)
356
- return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
356
+ return session_not_found(m.captures.first)
357
357
  end
358
358
 
359
- return session_route(principal) { send(handler, req, *m.captures) } if kind == :session
359
+ return session_route(principal, m.captures.first) { send(handler, req, *m.captures) } if kind == :session
360
360
 
361
361
  return send(handler, req, *m.captures)
362
362
  end
@@ -383,11 +383,14 @@ module Samagotchi
383
383
  # raises (Session.load, the manager's find) is a 404 not_found with
384
384
  # its message. Only these routes: elsewhere an ArgumentError is a bug,
385
385
  # #route's 500. A handler's own rescues run first.
386
- def session_route(principal = nil)
386
+ def session_route(principal = nil, id = nil)
387
387
  yield
388
388
  rescue ArgumentError => e
389
- # A participant's 404 never carries the lookup's words (a path).
390
- error_response(404, "not_found", principal.nil? || principal.owner? ? e.message : "Session not found")
389
+ return error_response(404, "not_found", e.message) if principal.nil? || principal.owner?
390
+
391
+ # A participant's 404 never carries a lookup's words (a path): it
392
+ # names their own session's id (valid_id? passed), nothing else.
393
+ session_not_found(id)
391
394
  end
392
395
 
393
396
  # API requests at debug level (not the page and its assets): method,
@@ -1152,7 +1155,7 @@ module Samagotchi
1152
1155
  Degrade.call(:web, "model_sampling_unreadable") do
1153
1156
  return if registry.nil? || models.empty?
1154
1157
 
1155
- settings = ConfigFile.model_settings
1158
+ settings = ModelSettings.all
1156
1159
  models.each do |model|
1157
1160
  target = registry.resolve(model[:name])
1158
1161
  names = registry.lookup_names(model[:name], target: target)
@@ -1214,7 +1217,7 @@ module Samagotchi
1214
1217
  @models_mutex.synchronize do
1215
1218
  next @registry if @registry_given
1216
1219
 
1217
- hosts = ConfigFile.hosts_config
1220
+ hosts = HostConfig.all
1218
1221
  if @registry.nil? || hosts != @registry_hosts
1219
1222
  @registry = HostRegistry.new(hosts_config: hosts)
1220
1223
  @registry_hosts = hosts
@@ -1560,7 +1563,7 @@ module Samagotchi
1560
1563
  begin
1561
1564
  session = @session_class.load(id, state_dir: default_state_dir)
1562
1565
  rescue ArgumentError
1563
- return error_response(404, "not_found", "Session not found: #{id}")
1566
+ return session_not_found(id)
1564
1567
  end
1565
1568
  # Its terminal's alone: a tab's stream would keep its worker up.
1566
1569
  return scratch_refused(id) if session.scratch
@@ -1976,6 +1979,10 @@ module Samagotchi
1976
1979
  error_response(404, "not_found", "not found: #{path}")
1977
1980
  end
1978
1981
 
1982
+ # 404 not_found for an id (or prefix) that names no session, in
1983
+ # Session::NotFound's words (SessionAccess::Remote maps it back).
1984
+ def session_not_found(given) = error_response(404, "not_found", Session::NotFound.new(given).message)
1985
+
1979
1986
  # One tab's GET /api/events: a bounded queue behind a lambda sink,
1980
1987
  # subscribed and snapshotted as one step under the hub's lock, then
1981
1988
  # drained onto the socket. The loop ends when the hub stops or the
@@ -185,7 +185,7 @@ module Samagotchi
185
185
  end
186
186
 
187
187
  def unknown_session(id)
188
- error_response(404, "not_found", "Session not found: #{id.to_s[0, 80]}")
188
+ session_not_found(id)
189
189
  end
190
190
 
191
191
  # 403 not_permitted for a participant (the owner is never refused by
@@ -388,7 +388,7 @@ module Samagotchi
388
388
  def participant_resolve(grant, given)
389
389
  return json_response(200, { id: grant.session_id }) if grant.session_id.start_with?(given)
390
390
 
391
- error_response(404, "not_found", "no session #{given[0, 80]}")
391
+ session_not_found(given)
392
392
  end
393
393
 
394
394
  # POST …/wait/prepare for a participant (an agent's chi --remote
@@ -78,14 +78,14 @@ module Samagotchi
78
78
  # 404 not_found; 400 ambiguous_id with ids (the sessions it names).
79
79
  def handle_resolve(req)
80
80
  given = req.params["id"].to_s
81
- return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
81
+ return session_not_found(given) unless Session.valid_id?(given)
82
82
  return participant_resolve(principal(req).grant, given) unless principal(req).owner?
83
83
 
84
84
  json_response(200, { id: session_access.resolve(given) })
85
85
  rescue Session::AmbiguousId => e
86
86
  refusal_response(400, "ambiguous_id", e, ids: e.ids)
87
- rescue ArgumentError
88
- error_response(404, "not_found", "no session #{given[0, 80]}")
87
+ rescue ArgumentError # NotFound, a file that won't load
88
+ session_not_found(given)
89
89
  end
90
90
 
91
91
  # GET /api/sessions/:id?brief=1: the session as a session command
@@ -47,7 +47,7 @@ module Samagotchi
47
47
 
48
48
  def handle_question_answer(req, id)
49
49
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
50
- return error_response(404, "not_found", "Session not found: #{id}") unless session
50
+ return session_not_found(id) unless session
51
51
 
52
52
  body = parse_json(request_body(req))
53
53
  return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
@@ -239,7 +239,7 @@ module Samagotchi
239
239
  return [] if raw.nil?
240
240
 
241
241
  session_dir = image_session_dir(id)
242
- return "unknown session" unless session_dir
242
+ return Session::NotFound.new(id).message unless session_dir
243
243
 
244
244
  ImageStore.check_refs(session_dir, raw)
245
245
  end
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # (converted, downscaled); answers the ref the turn then names.
260
260
  def handle_image_upload(req, id)
261
261
  session_dir = image_session_dir(id)
262
- return error_response(404, "not_found", "unknown session") unless session_dir
262
+ return session_not_found(id) unless session_dir
263
263
 
264
264
  length = req.content_length.to_i
265
265
  if length > MAX_IMAGE_UPLOAD_BYTES
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  end
141
141
 
142
142
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
143
- # link of that session's chain (SessionManager.continue_session), in its
143
+ # link of that session's chain (SessionContinue.run), in its
144
144
  # folder and with its own setup (SessionSetup: llm_context, thinking
145
145
  # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
146
146
  # refused beside it (a create takes no reasoning_history). It runs on
@@ -328,11 +328,11 @@ module Samagotchi
328
328
  [level, nil]
329
329
  end
330
330
 
331
- # SessionManager.continue_session, its refusals as responses: [session,
331
+ # SessionContinue.run, its refusals as responses: [session,
332
332
  # nil] or [nil, the response]. Only its own errors: what fails after
333
333
  # the new link started is not a refusal of it.
334
334
  def continue_from(given, prompt:, preview:, model:)
335
- [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
335
+ [SessionContinue.run(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
336
336
  rescue SessionContinue::Refused => e
337
337
  extra = e.reason == :continued ? { next_id: e.ids.first } : {}
338
338
  [nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
@@ -412,7 +412,7 @@ module Samagotchi
412
412
  # read is mtime-cached), so a host added or a window changed while
413
413
  # chi web runs is picked up on the next refresh of a card.
414
414
  def hub_registry
415
- hosts = ConfigFile.hosts_config
415
+ hosts = HostConfig.all
416
416
  if @hub_registry.nil? || hosts != @hub_registry_hosts
417
417
  @hub_registry = HostRegistry.new(hosts_config: hosts)
418
418
  @hub_registry_hosts = hosts
@@ -184,7 +184,7 @@ module Samagotchi
184
184
  # cards from its light GET tail, else the saved ones; the session file
185
185
  # is only stat'ed (the 404), never parsed.
186
186
  def handle_cards_read(id)
187
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
187
+ return session_not_found(id) unless session_exists?(id)
188
188
 
189
189
  tail = live_tail(id)
190
190
  json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
@@ -201,7 +201,7 @@ module Samagotchi
201
201
  # are the newest turn's (#recent_timing), which the page merges by id
202
202
  # (the page still sends ?recent=1, which is accepted and not needed).
203
203
  def handle_tail_read(req, id)
204
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
204
+ return session_not_found(id) unless session_exists?(id)
205
205
 
206
206
  turn_id = req.params["turn_id"].to_s.strip
207
207
  turn_id = nil if turn_id.empty?
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # the live ones) and turn_count, when the page's merge comes up short.
260
260
  # The worker's GET state, never its messages.
261
261
  def handle_timing_read(id)
262
- return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
262
+ return session_not_found(id) unless session_exists?(id)
263
263
 
264
264
  state = bridge_get_json(id, "state")
265
265
  json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
@@ -146,13 +146,12 @@ module Samagotchi
146
146
  private_class_method :budget_resolver
147
147
 
148
148
  # status is turn state (idle/running). The live worker's snapshot is the
149
- # truth; on disk, a "running" with no live owner was left by a worker
150
- # that died mid-turn.
149
+ # truth; on disk, Session#displayed_status (a "running" with no live
150
+ # owner was left by a worker that died mid-turn).
151
151
  def displayed_status(session, snapshot = nil, owner:)
152
152
  return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
153
- return session.status unless session.status == Session::STATUS_RUNNING
154
153
 
155
- owner ? session.status : Session::STATUS_IDLE
154
+ session.displayed_status(owner: owner)
156
155
  end
157
156
 
158
157
  def first_preview_for(session)