samagotchi 0.139.1 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +3 -1
  6. data/docs/configuration.md +33 -1
  7. data/docs/context.md +249 -46
  8. data/docs/guardrails.md +11 -0
  9. data/docs/internals/engine.md +7 -1
  10. data/docs/internals/prompt-caching.md +10 -1
  11. data/docs/internals/remote-sessions.md +4 -1
  12. data/docs/plugins.md +75 -18
  13. data/docs/sessions.md +3 -2
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/config.rb +8 -0
  24. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  25. data/lib/samagotchi/context_candidates.rb +259 -0
  26. data/lib/samagotchi/context_candidates_log.rb +90 -0
  27. data/lib/samagotchi/context_command.rb +65 -17
  28. data/lib/samagotchi/context_judge.rb +194 -0
  29. data/lib/samagotchi/context_move.rb +31 -0
  30. data/lib/samagotchi/context_report_command.rb +161 -0
  31. data/lib/samagotchi/context_sources.rb +311 -29
  32. data/lib/samagotchi/engine.rb +189 -32
  33. data/lib/samagotchi/owner_prompt.rb +39 -0
  34. data/lib/samagotchi/plugin/api.rb +1 -0
  35. data/lib/samagotchi/plugin/attached_context.rb +105 -13
  36. data/lib/samagotchi/plugin/context.rb +12 -1
  37. data/lib/samagotchi/plugin/peer.rb +53 -0
  38. data/lib/samagotchi/plugin/sessions.rb +9 -2
  39. data/lib/samagotchi/prompt_warmup.rb +9 -1
  40. data/lib/samagotchi/send_command.rb +3 -1
  41. data/lib/samagotchi/session.rb +4 -0
  42. data/lib/samagotchi/session_access/contract.rb +4 -0
  43. data/lib/samagotchi/session_access/local.rb +34 -3
  44. data/lib/samagotchi/session_access/remote.rb +33 -8
  45. data/lib/samagotchi/session_access.rb +31 -6
  46. data/lib/samagotchi/session_commands.rb +28 -5
  47. data/lib/samagotchi/session_continue.rb +9 -2
  48. data/lib/samagotchi/side_judgments.rb +84 -0
  49. data/lib/samagotchi/terminal_ui.rb +15 -0
  50. data/lib/samagotchi/testing/boundary.rb +5 -1
  51. data/lib/samagotchi/testing/host.rb +4 -1
  52. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  53. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +24 -11
  56. data/lib/samagotchi/web/auto_update.rb +8 -1
  57. data/lib/samagotchi/web/public/app.js +8 -2
  58. data/lib/samagotchi/web/public/chains.js +10 -2
  59. data/lib/samagotchi/web/public/update_notice.js +21 -8
  60. data/lib/samagotchi/web/session_access_routes.rb +8 -3
  61. data/lib/samagotchi/web/session_create.rb +1 -0
  62. data/lib/samagotchi/web/session_hub.rb +5 -2
  63. data/lib/samagotchi/web/update_check.rb +14 -8
  64. data/lib/samagotchi/worker.rb +5 -0
  65. metadata +9 -1
@@ -46,6 +46,12 @@ require_relative "llm/turn_settings"
46
46
  require_relative "session"
47
47
  require_relative "toolkit_binding"
48
48
  require_relative "participant_mode"
49
+ require_relative "context_candidates"
50
+ require_relative "context_candidates_log"
51
+ require_relative "context_candidate_choice"
52
+ require_relative "context_judge"
53
+ require_relative "owner_prompt"
54
+ require_relative "side_judgments"
49
55
  require_relative "toolkit_suggest"
50
56
  require_relative "tool_loads"
51
57
  require_relative "answer_tail"
@@ -162,7 +168,12 @@ module Samagotchi
162
168
  @no_interrupt = no_interrupt
163
169
  @chat_backend = nil
164
170
  @chat_backend_mutex = Mutex.new
165
- @toolkit_audit_thread = nil
171
+ @side_judgments = SideJudgments.new
172
+ @context_judge_after_turn = nil
173
+ # On mode's cards an answer brought while a turn ran: shown once it
174
+ # has ended (#show_context_cards_after_turn).
175
+ @context_cards_after_turn = []
176
+ @context_cards_mutex = Mutex.new
166
177
  @toolkit_warmup_thread = nil
167
178
  @host_registry = host_registry || HostRegistry.new
168
179
  # A model given here (a worker's session model) is the one it runs,
@@ -1123,10 +1134,10 @@ module Samagotchi
1123
1134
 
1124
1135
  # How long #shutdown waits for the running anytime commands, in all.
1125
1136
  SHUTDOWN_JOIN_SECONDS = 3.0
1126
- # How long #shutdown waits for a running toolkit audit ask (a one-shot
1127
- # -p --non-interactive exits right after its turn); a slower ask goes
1128
- # unlogged.
1129
- TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
1137
+ # How long #shutdown waits for the running side judgments (System 1
1138
+ # asks: SideJudgments; a one-shot -p --non-interactive exits right
1139
+ # after its turn), in all; a slower ask goes unlogged.
1140
+ SIDE_JUDGMENTS_JOIN_SECONDS = 1.0
1130
1141
 
1131
1142
  # The REPL or the session's worker is leaving (/exit, an idle exit, a
1132
1143
  # crash, TERM): stop the idle jobs, give the running anytime commands
@@ -1152,7 +1163,7 @@ module Samagotchi
1152
1163
 
1153
1164
  thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1154
1165
  end
1155
- @toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
1166
+ @side_judgments.join(SIDE_JUDGMENTS_JOIN_SECONDS)
1156
1167
  # The warm-up's answer is dropped anyway.
1157
1168
  stop_toolkit_warmup
1158
1169
  left = threads.count(&:alive?)
@@ -2305,7 +2316,10 @@ module Samagotchi
2305
2316
  pending_input: pending_input)
2306
2317
  publish_used_memories(session, on_event)
2307
2318
  complete_turn(turn, result)
2308
- warm_up_next_turn(turn, result)
2319
+ # The candidate judge's asks a shared local server kept for the
2320
+ # turn's end go first; the warm-up follows them and re-warms the
2321
+ # prefix they evicted.
2322
+ warm_up_next_turn(turn, result, after: run_context_judge_after_turn)
2309
2323
  result
2310
2324
  rescue Interrupt => e
2311
2325
  # After the end event (in the after_turn/session_end hooks) the
@@ -2332,6 +2346,8 @@ module Samagotchi
2332
2346
  raise
2333
2347
  ensure
2334
2348
  release_turn(probe_cancel_before)
2349
+ show_context_cards_after_turn
2350
+ run_context_judge_after_turn
2335
2351
  end
2336
2352
  end
2337
2353
 
@@ -2432,6 +2448,10 @@ module Samagotchi
2432
2448
  # On mode: the suggested kit, before anything is declared (a Stop
2433
2449
  # during the wait is caught by the check below).
2434
2450
  settle_toolkit_suggestion(turn, toolkit_suggestion)
2451
+ # The candidate judge: its asks run beside the turn on a remote
2452
+ # System 1 host, after it on the model's own local one; on mode's
2453
+ # attaches reach the model at the next between-turn delivery.
2454
+ start_context_judge(turn)
2435
2455
  apply_toolkit!(session, turn.on_event)
2436
2456
  refresh_participants!(session)
2437
2457
  # A Stop during the waits above: the turn ends here,
@@ -2509,7 +2529,7 @@ module Samagotchi
2509
2529
  # start differs anyway or starts at once: a reminder due, a continue
2510
2530
  # offer (the turn ran out of steps), a carried steer or queued input.
2511
2531
  # Never fails the turn.
2512
- def warm_up_next_turn(turn, result)
2532
+ def warm_up_next_turn(turn, result, after: nil)
2513
2533
  return if result.canceled? || result.resumable?
2514
2534
 
2515
2535
  model = effective_model
@@ -2524,7 +2544,7 @@ module Samagotchi
2524
2544
  return unless prompt
2525
2545
 
2526
2546
  started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
2527
- slot: @kernel.last_slot, images: images)
2547
+ slot: @kernel.last_slot, images: images, after: after)
2528
2548
  @kernel.warmed_up! if started
2529
2549
  rescue StandardError => e
2530
2550
  Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
@@ -3030,7 +3050,7 @@ module Samagotchi
3030
3050
 
3031
3051
  # One ask at a time: an eligible turn while the last one still runs
3032
3052
  # goes unasked, and says so.
3033
- if @toolkit_audit_thread&.alive?
3053
+ if @side_judgments.in_flight?(:toolkit)
3034
3054
  Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
3035
3055
  return
3036
3056
  end
@@ -3042,7 +3062,7 @@ module Samagotchi
3042
3062
  # with the turn's prefill, and any ask evicts the session's cached
3043
3063
  # prefix: on mode asks only on a first turn there (nothing is cached
3044
3064
  # yet worth keeping), never on the deferred path.
3045
- if (!joined || ToolkitSuggest.model_reply?(session.messages)) && toolkit_judge_shares_local_host?(client.host)
3065
+ if (!joined || ToolkitSuggest.model_reply?(session.messages)) && system_one_shares_local_host?(client.host)
3046
3066
  Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
3047
3067
  return
3048
3068
  end
@@ -3076,18 +3096,9 @@ module Samagotchi
3076
3096
  # On mode: cut by a Stop while the turn waits (Pending#wait), not
3077
3097
  # by one after it went on; audit's ask is never cut.
3078
3098
  controller = joined ? pending.cancel_controller : nil
3079
- @toolkit_audit_thread = Thread.new do
3080
- judged = nil
3081
- begin
3082
- judged = ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
3083
- cancel_controller: controller)
3084
- rescue LLM::RequestCancelled
3085
- nil
3086
- rescue StandardError => e
3087
- ToolkitSuggest.warn(e)
3088
- ensure
3089
- pending.finish(judged)
3090
- end
3099
+ @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
3100
+ ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
3101
+ cancel_controller: controller)
3091
3102
  end
3092
3103
  joined ? pending : nil
3093
3104
  rescue StandardError => e
@@ -3174,7 +3185,7 @@ module Samagotchi
3174
3185
  return unless client
3175
3186
  # D7: on the model's own single-slot server it would evict the
3176
3187
  # session's cached prefix for a question that may never come.
3177
- return if toolkit_judge_shares_local_host?(client.host)
3188
+ return if system_one_shares_local_host?(client.host)
3178
3189
 
3179
3190
  project = File.basename(session.project_root.to_s)
3180
3191
  @toolkit_warmup_thread = Thread.new do
@@ -3195,18 +3206,14 @@ module Samagotchi
3195
3206
  private :stop_toolkit_warmup
3196
3207
 
3197
3208
  def toolkit_suggestion_log
3198
- path = ToolkitSuggest.log_path(session_state_dir)
3199
- SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
3200
- warn: lambda { |reason|
3201
- Log.warn(:toolkit, "toolkit_suggest_log_failed", echo: "[samagotchi:toolkit] #{reason}")
3202
- })
3209
+ SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
3203
3210
  end
3204
3211
  private :toolkit_suggestion_log
3205
3212
 
3206
3213
  # D7: System 1's host is the session's model server, and a local one
3207
3214
  # (a single-slot Splash or llama.cpp): the same name, or two hosts:
3208
3215
  # entries for one server root (scheme, host, port).
3209
- def toolkit_judge_shares_local_host?(judge_name)
3216
+ def system_one_shares_local_host?(judge_name)
3210
3217
  model_host, = @host_registry.host_for_model(effective_model_name)
3211
3218
  return false if model_host.nil? || model_host.remote?
3212
3219
  return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
@@ -3214,7 +3221,156 @@ module Samagotchi
3214
3221
  judge = @host_registry.find_entry(judge_name)
3215
3222
  !judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
3216
3223
  end
3217
- private :toolkit_judge_shares_local_host?
3224
+ private :system_one_shares_local_host?
3225
+
3226
+ # The candidate judge (ContextJudge) at an owner turn's start, after
3227
+ # the toolkit's ask settled: the session's candidates' status changes
3228
+ # are logged (ContextCandidatesLog#look) and, unless
3229
+ # context.candidates.mode is off, the proposed candidates due an ask
3230
+ # are asked about the owner's prompts so far, this one included. Never
3231
+ # a participant's turn, a review session or a Continue (no new prompt).
3232
+ def start_context_judge(turn)
3233
+ session = turn.session
3234
+ return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
3235
+
3236
+ context_judge_pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
3237
+ end
3238
+ private :start_context_judge
3239
+
3240
+ # A late proposal no owner prompt mentions (Plugin::Host
3241
+ # #candidate_proposed: github-pr's init task runs beside the first
3242
+ # turn): judged on the owner's prompts so far, the running turn's too.
3243
+ def judge_late_candidates
3244
+ session = @session
3245
+ return if session.nil? || session.review?
3246
+
3247
+ texts = ContextCandidates.owner_texts(plugin_messages)
3248
+ context_judge_pass(session, texts, phase: "late") unless texts.empty?
3249
+ end
3250
+ private :judge_late_candidates
3251
+
3252
+ # One look at +session+'s candidates; the asks go to SideJudgments'
3253
+ # :context kind, one thread asking each due candidate in turn. On the
3254
+ # model's own local server (#system_one_shares_local_host?) the asks
3255
+ # wait for the running turn's end (#run_context_judge_after_turn),
3256
+ # never competing with its prefill; any other host is asked now.
3257
+ def context_judge_pass(session, texts, phase:)
3258
+ return if session.id.nil? || session.scratch
3259
+
3260
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: session_state_dir)
3261
+ return if rows.empty?
3262
+
3263
+ log = ContextCandidatesLog.log_for(session_state_dir)
3264
+ records = ContextCandidatesLog.records_for(log, session.id)
3265
+ ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
3266
+ settings = ContextJudge::Settings.from_config
3267
+ return if settings.mode == "off"
3268
+
3269
+ jobs = ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings, phase: phase,
3270
+ parent_attached: context_parent_attached(session))
3271
+ return if jobs.empty?
3272
+
3273
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context)
3274
+ return unless client
3275
+
3276
+ choice = context_choice(session, log, settings)
3277
+ ask = -> { start_context_asks(client, jobs, log, settings, choice) }
3278
+ if turn_running? && system_one_shares_local_host?(client.host)
3279
+ Log.info(:context, "context_judge_after_turn", session_id: session.id, host: client.host)
3280
+ @context_judge_after_turn = ask
3281
+ else
3282
+ ask.call
3283
+ end
3284
+ rescue StandardError => e
3285
+ ContextJudge.warn(e)
3286
+ end
3287
+ private :context_judge_pass
3288
+
3289
+ # @return [Thread, nil] the asks' thread, nil when none started
3290
+ def start_context_asks(client, jobs, log, settings, choice)
3291
+ if @side_judgments.in_flight?(:context)
3292
+ Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
3293
+ return nil
3294
+ end
3295
+
3296
+ @side_judgments.start(:context, warn: ContextJudge.method(:warn)) do
3297
+ jobs.each { |job| choice.call(job, ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
3298
+ end
3299
+ end
3300
+ private :start_context_asks
3301
+
3302
+ # What an answer does on the asks' thread (ContextCandidateChoice
3303
+ # #after_judged: on mode attaches or cards; audit nothing). Taken on
3304
+ # the caller's thread. The attach only writes the session's folder: its
3305
+ # note comes with the next between-turn delivery, never into a running
3306
+ # turn. A card shows only while this Engine is still on that session,
3307
+ # and not in a running turn (it isn't the turn's: no "waiting for you"
3308
+ # on it), but once it has ended.
3309
+ # @return [#call] (job, fields)
3310
+ def context_choice(session, log, settings)
3311
+ session_id = session.id
3312
+ project_root = session.project_root
3313
+ state_dir = session_state_dir
3314
+ show = lambda do |**card|
3315
+ current = @session
3316
+ next nil unless current.nil? || current.id == session_id
3317
+
3318
+ @context_cards_mutex.synchronize do
3319
+ next @context_cards_after_turn << card if turn_running?
3320
+
3321
+ show_card(**card)
3322
+ end
3323
+ end
3324
+ lambda do |job, fields|
3325
+ ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
3326
+ state_dir: state_dir, log: log, show_card: show)
3327
+ end
3328
+ end
3329
+ private :context_choice
3330
+
3331
+ # The cards #context_choice held while a turn ran (#run_turn's ensure,
3332
+ # after the turn flag is released).
3333
+ def show_context_cards_after_turn
3334
+ cards = @context_cards_mutex.synchronize { @context_cards_after_turn.slice!(0..) }
3335
+ cards.each { |card| show_card(**card) }
3336
+ rescue StandardError => e
3337
+ ContextJudge.warn(e)
3338
+ end
3339
+ private :show_context_cards_after_turn
3340
+
3341
+ # The asks a shared local host kept for the turn's end: a completed
3342
+ # turn starts them before its warm-up (which waits for them,
3343
+ # PromptWarmup#start's after:); a turn that ended otherwise, from
3344
+ # #run_turn's ensure.
3345
+ # @return [Thread, nil] the asks' thread
3346
+ def run_context_judge_after_turn
3347
+ ask = @context_judge_after_turn or return nil
3348
+ @context_judge_after_turn = nil
3349
+ ask.call
3350
+ rescue StandardError => e
3351
+ ContextJudge.warn(e)
3352
+ nil
3353
+ end
3354
+ private :run_context_judge_after_turn
3355
+
3356
+ # A delegate child's parent's sources, by title (else name): the
3357
+ # judge's started_from. nil for any other session (a chi send --new
3358
+ # session has no parent; a fork isn't a delegate).
3359
+ def context_parent_attached(session)
3360
+ return nil unless session.delegate? && session.parent_id
3361
+
3362
+ parent_root = if Session.exist?(session.parent_id, state_dir: session_state_dir)
3363
+ Session.load(session.parent_id, state_dir: session_state_dir).project_root
3364
+ else
3365
+ session.project_root
3366
+ end
3367
+ ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: session_state_dir)
3368
+ .map { |attached| attached.source.title || attached.name }
3369
+ rescue StandardError => e
3370
+ Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3371
+ []
3372
+ end
3373
+ private :context_parent_attached
3218
3374
 
3219
3375
  # The session's toolkit (see #initialize for when).
3220
3376
  def build_toolkit_binding
@@ -3294,7 +3450,8 @@ module Samagotchi
3294
3450
  usage: -> { @metrics.usage },
3295
3451
  turn_author: -> { turn_author },
3296
3452
  # A plugin's prompt section is in the stable part: a full re-read.
3297
- cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) }
3453
+ cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
3454
+ candidate_proposed: ->(_candidate) { judge_late_candidates }
3298
3455
  )
3299
3456
  end
3300
3457
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "message_time"
4
+ require_relative "steer"
5
+
6
+ module Samagotchi
7
+ # The session owner's own words: the one rule for what may act on a
8
+ # session's conversation by itself (toolkit suggest, attached context's
9
+ # candidates). A participant's turn and a review session never do.
10
+ module OwnerPrompt
11
+ module_function
12
+
13
+ # The owner's own prompt: a user line the Engine kept for a turn (it
14
+ # carries the turn's id), or TurnCheckpoint's line for a turn killed
15
+ # before its first boundary (stamped, no id). A !command's output, a
16
+ # declined continue's reason and KernelLoop's recovery prompt are
17
+ # neither; steers, notes and reminders carry their kind.
18
+ def message?(message)
19
+ field(message, :role).to_s == "user" &&
20
+ !Steer.participant_author?(field(message, :author)) &&
21
+ !%w[input steer note reminder].include?(field(message, :kind).to_s) &&
22
+ !field(message, :preserve_literals) &&
23
+ !(field(message, :turn_id) || field(message, MessageTime::KEY)).nil?
24
+ end
25
+
26
+ # Whether a turn by +author+ in +session+ is the owner's own: not a
27
+ # participant's, not in a review session.
28
+ def turn?(session:, author:)
29
+ !session&.review? && !Steer.participant_author?(author)
30
+ end
31
+
32
+ def field(hash, key)
33
+ return nil unless hash.is_a?(Hash)
34
+
35
+ hash.key?(key) ? hash[key] : hash[key.to_s]
36
+ end
37
+ private_class_method :field
38
+ end
39
+ end
@@ -49,6 +49,7 @@ module Samagotchi
49
49
  "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0",
50
50
  "chi.prompt_section" => "0.132.0", "chi.prompt_changed" => "0.132.0", "ctx.cache_confirm" => "0.132.0",
51
51
  "ctx.model_names" => "0.132.0"
52
+ # Unreleased: "ctx.context.propose", "ctx.context.candidates", "ctx.context.withdraw", "ctx.review".
52
53
  }.freeze
53
54
 
54
55
  # @param bundle [String]
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "../context_candidates"
5
+ require_relative "../context_candidates_log"
4
6
  require_relative "../context_sources"
5
7
  require_relative "../context_providers"
6
8
  require_relative "../session"
@@ -29,12 +31,15 @@ module Samagotchi
29
31
  # session (its name, or its URL) isn't attached again (#declined?)
30
32
  # until the user adds it again, or +force+: the user's own explicit
31
33
  # choice (a click), which clears the declined marker (by the name,
32
- # and by the URL).
34
+ # and by the URL). +movable+: whether a continue or a fork takes it
35
+ # to the new session (nil: the default, a command source moves;
36
+ # ContextSources::Source#movable?).
33
37
  # @return [String, nil] the source's name; nil when declined
34
38
  # @raise [Error]
35
- def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
39
+ def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false, movable: nil)
36
40
  own = own_location!
37
41
  source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
42
+ source = source.with(movable: movable ? true : false) unless movable.nil?
38
43
  if force
39
44
  own.undecline(source.name, hint: url && source.hint)
40
45
  elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
@@ -76,22 +81,65 @@ module Samagotchi
76
81
  name_declined?(own, name) || (!url.to_s.empty? && own.declined_hints.include?(url))
77
82
  end
78
83
 
79
- # Records that this plugin offered +name+ (+hint+: its URL; +why+: the
80
- # reason an attach from the offer gives) here, so a worker restart
81
- # doesn't offer it again.
82
- # @return [ContextSources::Offer]
84
+ # Proposes a source for this session without attaching it: +url+
85
+ # (through an installed bundle's provider; +name+ replaces the
86
+ # provider's) or +name+ and +cmd+. +title+ is what a person (or a
87
+ # judge) reads, +describe+ more of it, +mentions+ the words an owner
88
+ # prompt names it by (ContextCandidates.mention_in). Idempotent by
89
+ # name: a candidate of that name stays as first proposed. Nothing is
90
+ # proposed in a review session, for a source attached already (by its
91
+ # name or URL, at any scope) or for one the user removed from the
92
+ # session (its name, or its URL). A new candidate an owner prompt
93
+ # already in the session mentions is attached at once (its note comes
94
+ # with the next turn).
95
+ # @return [ContextSources::Candidate, nil] nil when nothing was proposed
83
96
  # @raise [Error]
84
- def mark_offered(name, hint, why: nil)
85
- own_location!.mark_offered(name, hint, why: why)
86
- rescue ContextSources::Invalid => e
97
+ def propose(title:, url: nil, name: nil, cmd: nil, describe: nil, mentions: [])
98
+ own = own_location!
99
+ candidate = new_candidate(url: url, name: name, cmd: cmd, title: title, describe: describe, mentions: mentions)
100
+ return nil if own.declined?(candidate.name) || (url && own.declined_hints.include?(candidate.url))
101
+
102
+ session = saved_session
103
+ return nil if session&.review? || attached?(candidate, session)
104
+
105
+ recorded = own.add_candidate(candidate)
106
+ if recorded.equal?(candidate)
107
+ ContextCandidatesLog.event("proposed", session_id: own.key, name: candidate.name, state_dir: state_dir,
108
+ title: candidate.title, by: candidate.proposed_by)
109
+ late_match(recorded, own)
110
+ end
111
+ recorded
112
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
87
113
  raise Error, e.message
88
114
  end
89
115
 
90
- # @return [ContextSources::Offer, nil] the offer #mark_offered recorded
91
- def offered(name)
92
- location&.offered(name)
116
+ # This session's candidates (#propose), by name; none before the
117
+ # session has an id.
118
+ # @return [Array<ContextSources::Candidate>]
119
+ def candidates
120
+ location&.candidates || []
121
+ end
122
+
123
+ # What the continue that started this session left with the previous
124
+ # link (pushed sources, a peer's pairing among them): each one's name,
125
+ # hint (its last pushed one, else the source's) and the session it
126
+ # stayed in. Read-only; none for a session that isn't a continue's.
127
+ # @return [Array<ContextSources::Left>]
128
+ def left_behind
129
+ location&.left || []
130
+ end
131
+
132
+ # Takes back this session's candidate +name+. A source attached from
133
+ # it stays attached (chi context rm removes that).
134
+ # @return [Boolean] whether there was one
135
+ def withdraw(name)
136
+ own = location or return false
137
+ return false unless own.remove_candidate(name)
138
+
139
+ ContextCandidatesLog.event("withdrawn", session_id: own.key, name: name, state_dir: state_dir)
140
+ true
93
141
  rescue ContextSources::Invalid
94
- nil
142
+ false
95
143
  end
96
144
 
97
145
  # This session's sources (its own, then its project's); none before
@@ -136,6 +184,30 @@ module Samagotchi
136
184
 
137
185
  def own_location! = location || raise(Error, "this session has no id yet")
138
186
 
187
+ # The session's saved file, nil before it has one.
188
+ def saved_session
189
+ id = @host.session_id.call
190
+ id && Session.exist?(id, state_dir: state_dir) ? Session.load(id, state_dir: state_dir) : nil
191
+ end
192
+
193
+ def attached?(candidate, session)
194
+ ContextSources.attached(session&.id || @host.session_id.call, project_root: session&.project_root,
195
+ state_dir: state_dir, shadowed: true)
196
+ .any? { |a| a.name == candidate.name || (candidate.url && a.source.hint == candidate.url) }
197
+ end
198
+
199
+ # A late proposal: the owner's prompts so far may name it already;
200
+ # one that none names goes to the candidate judge (the Engine's
201
+ # #judge_late_candidates, when the host has one).
202
+ def late_match(candidate, own)
203
+ messages = @host.messages&.call or return
204
+ return if ContextCandidates.after_propose(candidate, own: own, messages: messages, state_dir: state_dir)
205
+
206
+ @host.candidate_proposed&.call(candidate)
207
+ rescue StandardError => e
208
+ Samagotchi::Log.warn(:context, "candidate_late_match_failed", name: candidate.name, error: e.class.name)
209
+ end
210
+
139
211
  def location
140
212
  id = @host.session_id.call
141
213
  id && ContextSources.session_location(id, state_dir: state_dir)
@@ -147,6 +219,26 @@ module Samagotchi
147
219
  hint: resolved.hint, every_seconds: resolved.every_seconds, provider: resolved.bundle)
148
220
  end
149
221
 
222
+ def new_candidate(url:, name:, cmd:, title:, describe:, mentions:)
223
+ if url
224
+ resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
225
+ name = name ? ContextSources.check_name!(name) : resolved.name
226
+ cmd = nil
227
+ else
228
+ raise Error, "give url: or cmd: (with name:)" if cmd.to_s.strip.empty?
229
+
230
+ name = ContextSources.check_name!(name)
231
+ end
232
+ title = ContextSources.one_line(title, ContextSources::LINE_MAX_CHARS) or raise Error, "give title:"
233
+ ContextSources::Candidate.new(
234
+ name: name, url: url&.to_s, cmd: cmd&.to_s, title: title,
235
+ describe: ContextSources.one_line(describe, ContextSources::DESCRIBE_MAX_CHARS),
236
+ mentions: Array(mentions).filter_map { |word| ContextSources.one_line(word.to_s, ContextSources::LINE_MAX_CHARS) }
237
+ .uniq.first(ContextSources::MENTIONS_MAX),
238
+ proposed_by: "plugin:#{@bundle}", at: Time.now.utc.iso8601
239
+ )
240
+ end
241
+
150
242
  def from_cmd(name:, cmd:, why:, hint:, every_seconds:)
151
243
  raise Error, "give url: or cmd: (with name:)" if cmd.to_s.strip.empty?
152
244
 
@@ -63,11 +63,13 @@ module Samagotchi
63
63
  # CacheConfirm::Confirm when the change must ask first (CacheConfirm.ask
64
64
  # with cost :full), else nil. +model_names+ returns the names the
65
65
  # session's model is looked up under (EffectiveModel#names).
66
+ # +candidate_proposed+ takes (candidate): a late proposal no owner
67
+ # prompt mentions, for the candidate judge (Engine#judge_late_candidates).
66
68
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
67
69
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
68
70
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
69
71
  :cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
70
- keyword_init: true)
72
+ :candidate_proposed, keyword_init: true)
71
73
 
72
74
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
73
75
  # sent nothing usable. The message says why, for the user.
@@ -265,6 +267,15 @@ module Samagotchi
265
267
  # another session handed over, not the user's own.
266
268
  def delegate? = !!saved_session&.delegate?
267
269
 
270
+ # Whether the session is a review session (`chi review`, Session#review?):
271
+ # one its participants read, not the owner's working session.
272
+ def review? = !!saved_session&.review?
273
+
274
+ # The id of the session this one continues (a continue's new link,
275
+ # Session#continues), else nil.
276
+ # @return [String, nil]
277
+ def continues = saved_session&.continues
278
+
268
279
  # Whether the session is a fork (ctx.sessions.fork: /btw keep, a
269
280
  # plugin's): it has a parent and isn't a delegate child, and it
270
281
  # started from a conversation, so its first prompt isn't its first
@@ -40,6 +40,12 @@ module Samagotchi
40
40
  # quote. Such a text is titled by its first quoted line, not the quote's
41
41
  # `From your thinking:` label.
42
42
  #
43
+ # A continue leaves the pairing with the previous link (its inbox and
44
+ # outbox are pushed sources: ContextSources.move). The new link's quiet
45
+ # init task (#pair_again) posts the `moved` message to the inbox left
46
+ # behind, once, so the peer pairs again with the new id; its chi-peer
47
+ # skill knows how it paired.
48
+ #
43
49
  # A subclass (the bundle's Plugin) sets the strings, as constants:
44
50
  # INBOX, OUTBOX ("<peer>-inbox", "-outbox"), BUNDLE, NAME (the peer, as
45
51
  # the model and user call it: "Claude"), APP (its product: "Claude
@@ -62,6 +68,8 @@ module Samagotchi
62
68
  PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
63
69
  PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
64
70
  DIFF_LINES = 20
71
+ # Under the session's data dir: the moved message went (or was tried).
72
+ MOVED_MARKER = "moved-posted"
65
73
  # The label line a web quote starts with (annotations.js sourceLabel).
66
74
  QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
67
75
 
@@ -90,6 +98,7 @@ module Samagotchi
90
98
  run_command(args.to_s.strip, ctx)
91
99
  end
92
100
  chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate"
101
+ chi.init("Asking #{peer::NAME} to pair again", quiet: true) { |ctx| pair_again(ctx) }
93
102
  # The outbox source is the peer's answers: its own wake budget and
94
103
  # line (context.peer_turns).
95
104
  chi.peer_source peer::OUTBOX, label: peer::NAME
@@ -130,8 +139,52 @@ module Samagotchi
130
139
  "sent to #{self.class::NAME}: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
131
140
  end
132
141
 
142
+ # A continue's new link whose previous link kept this peer's INBOX
143
+ # (ctx.context.left_behind): posts the moved message to that inbox's
144
+ # hint, once (a marker under the session's data dir: a worker restart
145
+ # doesn't post again). A post that fails is logged and dropped: no
146
+ # retry, and the peer's next push to the old id is refused with the
147
+ # new one (its skill pairs again then).
148
+ # @return [String] what it did (the quiet init task's summary)
149
+ def pair_again(ctx)
150
+ peer = self.class
151
+ previous = ctx.continues or return "not a continue"
152
+ id = ctx.session_id or return "no session yet"
153
+ left = ctx.context.left_behind.find { |row| row.name == peer::INBOX }
154
+ hint = left&.hint.to_s.strip
155
+ return "no #{peer::INBOX} left behind" if hint.empty?
156
+ return "asked already" unless claim_moved(ctx, id)
157
+
158
+ check_hint!(hint)
159
+ post(hint, moved_message(previous, id))
160
+ "asked #{peer::NAME} to pair again with #{id[0, 8]}"
161
+ rescue SendError, SystemCallError, ArgumentError => e
162
+ ctx.log.warn("moved_not_posted", error: e.message)
163
+ "couldn't ask #{peer::NAME} to pair again: #{e.message}"
164
+ end
165
+
166
+ # What the peer gets when +previous+ was continued as +id+: the usual
167
+ # header, kind moved, and how to go on. Not a literal pair command:
168
+ # the skill knows how its peer paired (a remote, an ssh socket).
169
+ def moved_message(previous, id)
170
+ short = previous.to_s[0, 8]
171
+ "chi #{short} · moved · continued as #{id}\n" \
172
+ "This chi session was continued as #{id}. Pair with #{id} the way you paired with #{short}, then " \
173
+ "answer there (chi context push #{self.class::OUTBOX} #{id})."
174
+ end
175
+
133
176
  private
134
177
 
178
+ # Takes the moved marker for session +id+; false when it is there.
179
+ def claim_moved(ctx, id)
180
+ File.open(File.join(session_dir(ctx, id), MOVED_MARKER), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
181
+ file.write("#{Time.now.utc.iso8601}\n")
182
+ end
183
+ true
184
+ rescue Errno::EEXIST
185
+ false
186
+ end
187
+
135
188
  # The transport: sends +text+ to the peer at +hint+ (the inbox's hint).
136
189
  # @raise [SendError]
137
190
  def post(_hint, _text) = raise(NotImplementedError, "#{self.class} must define #post")