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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +2 -1
- data/docs/architecture.md +8 -6
- data/docs/cli.md +3 -1
- data/docs/configuration.md +33 -1
- data/docs/context.md +249 -46
- data/docs/guardrails.md +11 -0
- data/docs/internals/engine.md +7 -1
- data/docs/internals/prompt-caching.md +10 -1
- data/docs/internals/remote-sessions.md +4 -1
- data/docs/plugins.md +75 -18
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/context_candidate_choice.rb +154 -0
- data/lib/samagotchi/context_candidates.rb +259 -0
- data/lib/samagotchi/context_candidates_log.rb +90 -0
- data/lib/samagotchi/context_command.rb +65 -17
- data/lib/samagotchi/context_judge.rb +194 -0
- data/lib/samagotchi/context_move.rb +31 -0
- data/lib/samagotchi/context_report_command.rb +161 -0
- data/lib/samagotchi/context_sources.rb +311 -29
- data/lib/samagotchi/engine.rb +189 -32
- data/lib/samagotchi/owner_prompt.rb +39 -0
- data/lib/samagotchi/plugin/api.rb +1 -0
- data/lib/samagotchi/plugin/attached_context.rb +105 -13
- data/lib/samagotchi/plugin/context.rb +12 -1
- data/lib/samagotchi/plugin/peer.rb +53 -0
- data/lib/samagotchi/plugin/sessions.rb +9 -2
- data/lib/samagotchi/prompt_warmup.rb +9 -1
- data/lib/samagotchi/send_command.rb +3 -1
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/contract.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +34 -3
- data/lib/samagotchi/session_access/remote.rb +33 -8
- data/lib/samagotchi/session_access.rb +31 -6
- data/lib/samagotchi/session_commands.rb +28 -5
- data/lib/samagotchi/session_continue.rb +9 -2
- data/lib/samagotchi/side_judgments.rb +84 -0
- data/lib/samagotchi/terminal_ui.rb +15 -0
- data/lib/samagotchi/testing/boundary.rb +5 -1
- data/lib/samagotchi/testing/host.rb +4 -1
- data/lib/samagotchi/toolkit_report_command.rb +2 -1
- data/lib/samagotchi/toolkit_suggest.rb +3 -18
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -11
- data/lib/samagotchi/web/auto_update.rb +8 -1
- data/lib/samagotchi/web/public/app.js +8 -2
- data/lib/samagotchi/web/public/chains.js +10 -2
- data/lib/samagotchi/web/public/update_notice.js +21 -8
- data/lib/samagotchi/web/session_access_routes.rb +8 -3
- data/lib/samagotchi/web/session_create.rb +1 -0
- data/lib/samagotchi/web/session_hub.rb +5 -2
- data/lib/samagotchi/web/update_check.rb +14 -8
- data/lib/samagotchi/worker.rb +5 -0
- metadata +9 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
|
1127
|
-
# -p --non-interactive exits right
|
|
1128
|
-
# unlogged.
|
|
1129
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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 @
|
|
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)) &&
|
|
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
|
-
@
|
|
3080
|
-
|
|
3081
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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")
|