samagotchi 0.30.0 → 0.32.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 +87 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +7 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -1
- data/lib/samagotchi/child_reports.rb +212 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +12 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +30 -7
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +37 -5
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +48 -7
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
- data/lib/samagotchi/terminal_ui.rb +9 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +28 -7
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -3
- data/lib/samagotchi/web/public/app.js +52 -14
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +34 -10
- data/lib/samagotchi/web/public/turn_view.js +26 -4
- data/lib/samagotchi/worker.rb +343 -8
- metadata +16 -1
|
@@ -5,9 +5,17 @@ module Samagotchi
|
|
|
5
5
|
# The gate's verdict for one tool call. Voters (hooks now, rules later)
|
|
6
6
|
# call #deny! or #ask!; the strictest vote wins (deny > ask > allow) and
|
|
7
7
|
# a vote never relaxes it, so a deny is sticky. Of two equal votes the
|
|
8
|
-
# first one stays
|
|
8
|
+
# first one stays, except that a protected rule's ask (PROTECTED_RULES)
|
|
9
|
+
# takes over an unprotected ask: its rule id is what a parent's answer
|
|
10
|
+
# and a stored approval are checked against, and the offered scopes
|
|
11
|
+
# narrow to both asks' common ones.
|
|
9
12
|
class Verdict
|
|
10
13
|
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
14
|
+
# Rules that ask about chi's own config and hooks: the core
|
|
15
|
+
# ProtectedPaths asks and the guardrails bundle's shell rule; and a
|
|
16
|
+
# command source for attached context (it runs later, ungated). Only
|
|
17
|
+
# the user may allow them (ParentApprovals).
|
|
18
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd].freeze
|
|
11
19
|
SCOPES = %w[once session repo rule].freeze
|
|
12
20
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
13
21
|
|
|
@@ -46,13 +54,18 @@ module Samagotchi
|
|
|
46
54
|
# @param scopes [Array<String>, nil] which approval scopes the user may
|
|
47
55
|
# pick (default all of SCOPES)
|
|
48
56
|
def ask!(reason, scopes: nil, rule: nil, source: nil, decided_by: "hook")
|
|
57
|
+
earlier = ask? ? @scopes : nil
|
|
49
58
|
return self unless vote(:ask, reason, rule: rule, source: source, decided_by: decided_by)
|
|
50
59
|
|
|
51
60
|
picked = Array(scopes).map(&:to_s) & SCOPES
|
|
52
|
-
|
|
61
|
+
picked = SCOPES if picked.empty?
|
|
62
|
+
picked &= earlier if earlier
|
|
63
|
+
@scopes = picked.empty? ? %w[once] : picked
|
|
53
64
|
self
|
|
54
65
|
end
|
|
55
66
|
|
|
67
|
+
def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
|
|
68
|
+
|
|
56
69
|
# A before_tool_call hook set the old event[:blocked] flag. Its model
|
|
57
70
|
# text stays "blocked by guardrail: <reason>".
|
|
58
71
|
def legacy_deny!(reason)
|
|
@@ -118,7 +131,7 @@ module Samagotchi
|
|
|
118
131
|
end
|
|
119
132
|
|
|
120
133
|
def vote(decision, reason, decided_by:, rule: nil, source: nil)
|
|
121
|
-
return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision)
|
|
134
|
+
return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision) || takes_over?(decision, rule)
|
|
122
135
|
|
|
123
136
|
@decision = decision
|
|
124
137
|
@reason = reason.to_s.strip
|
|
@@ -129,6 +142,11 @@ module Samagotchi
|
|
|
129
142
|
@legacy = false
|
|
130
143
|
true
|
|
131
144
|
end
|
|
145
|
+
|
|
146
|
+
# A protected rule's ask over an unprotected one.
|
|
147
|
+
def takes_over?(decision, rule)
|
|
148
|
+
decision == :ask && ask? && Verdict.protected_rule?(rule) && !Verdict.protected_rule?(@rule)
|
|
149
|
+
end
|
|
132
150
|
end
|
|
133
151
|
end
|
|
134
152
|
end
|
|
@@ -54,6 +54,7 @@ module Samagotchi
|
|
|
54
54
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
55
55
|
# The text the web sends for a turn of images alone (images.js turnText).
|
|
56
56
|
IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
57
|
+
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
57
58
|
|
|
58
59
|
module_function
|
|
59
60
|
|
|
@@ -71,6 +72,9 @@ module Samagotchi
|
|
|
71
72
|
# An image is a line naming it (refs only, never its bytes), unless
|
|
72
73
|
# the text is only the web's placeholder naming it already.
|
|
73
74
|
content = message["content"].to_s
|
|
75
|
+
# A delegate child's report (ChildReports) is chi's news, not
|
|
76
|
+
# what the user said.
|
|
77
|
+
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
74
78
|
images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
|
|
75
79
|
[content, *images].reject(&:empty?).join("\n")
|
|
76
80
|
when "model", "assistant"
|
|
@@ -97,6 +97,8 @@ module Samagotchi
|
|
|
97
97
|
|
|
98
98
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
99
99
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
|
|
100
|
+
# How much of a malformed generation's end the warning keeps.
|
|
101
|
+
MALFORMED_TAIL_CHARS = 400
|
|
100
102
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
|
|
101
103
|
|
|
102
104
|
# @param tools [Tools::Registry, nil] the tools calls dispatch to (the
|
|
@@ -242,7 +244,8 @@ module Samagotchi
|
|
|
242
244
|
# the prefill, and what the caller gave.
|
|
243
245
|
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
|
|
244
246
|
:qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
245
|
-
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty,
|
|
247
|
+
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, :malformed_retried,
|
|
248
|
+
:uncached_next, keyword_init: true)
|
|
246
249
|
# One request: the prompt and its images as sent, the images' token
|
|
247
250
|
# estimate, and the window it was measured against.
|
|
248
251
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
|
|
@@ -250,9 +253,10 @@ module Samagotchi
|
|
|
250
253
|
# generation's own server counts, the model the server named, the
|
|
251
254
|
# thinking it streamed, and why it stopped (the transport's finish
|
|
252
255
|
# reason, Client::Transport#finish_reason_from; nil when not named),
|
|
253
|
-
# and its prompt-cache counts (the largest seen: they come cumulative)
|
|
256
|
+
# and its prompt-cache counts (the largest seen: they come cumulative),
|
|
257
|
+
# and where its visible text starts in the turn's buffer.
|
|
254
258
|
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
|
|
255
|
-
keyword_init: true)
|
|
259
|
+
:buffer_mark, keyword_init: true)
|
|
256
260
|
private_constant :Turn, :Request, :Generation
|
|
257
261
|
|
|
258
262
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
@@ -278,6 +282,9 @@ module Samagotchi
|
|
|
278
282
|
request = prepare_request(turn)
|
|
279
283
|
generation = generate(turn, request)
|
|
280
284
|
return after_cut(turn, generation) if generation.cut
|
|
285
|
+
if (reason = parser.malformed(generation.response))
|
|
286
|
+
return after_malformed(turn, generation, reason)
|
|
287
|
+
end
|
|
281
288
|
|
|
282
289
|
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s }
|
|
283
290
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
@@ -330,10 +337,13 @@ module Samagotchi
|
|
|
330
337
|
# thinking_chars=N content_length=…).
|
|
331
338
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
332
339
|
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
333
|
-
buffer_mark = turn.buffer.length
|
|
340
|
+
buffer_mark = generation.buffer_mark = turn.buffer.length
|
|
334
341
|
# A cancel salvages only this generation's thinking: the earlier
|
|
335
342
|
# steps' is on their own messages already.
|
|
336
343
|
turn.streamed_thinking = +""
|
|
344
|
+
# Only the retry of a malformed generation skips the prompt cache.
|
|
345
|
+
uncached = turn.uncached_next
|
|
346
|
+
turn.uncached_next = false
|
|
337
347
|
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
338
348
|
request_generation(
|
|
339
349
|
request.prompt,
|
|
@@ -341,6 +351,7 @@ module Samagotchi
|
|
|
341
351
|
cancel_controller: turn.cancel_controller,
|
|
342
352
|
model_name: turn.model_name,
|
|
343
353
|
sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
|
|
354
|
+
cache_prompt: !uncached,
|
|
344
355
|
on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
|
|
345
356
|
on_retry: lambda { |retry_event|
|
|
346
357
|
# The retry streams from the start: its counts replace these.
|
|
@@ -462,6 +473,31 @@ module Samagotchi
|
|
|
462
473
|
cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
|
|
463
474
|
end
|
|
464
475
|
|
|
476
|
+
# A corrupt generation (ToolCallParser#malformed) is no answer and runs
|
|
477
|
+
# no tool: it is logged and dropped, with its streamed text, and the
|
|
478
|
+
# same prompt goes once more without the server's prompt cache (a
|
|
479
|
+
# poisoned cache made it, ggml-org/llama.cpp#27148), shown as the loop
|
|
480
|
+
# asking again. A second one fails the turn. Returns :next.
|
|
481
|
+
def after_malformed(turn, generation, reason)
|
|
482
|
+
response = generation.response.to_s
|
|
483
|
+
Log.warn(:model, "generation_malformed", iteration: turn.iteration, reason: reason, id_slot: @last_slot,
|
|
484
|
+
cache_n: generation.cache&.dig(:cached), finish_reason: generation.finish_reason,
|
|
485
|
+
retried: turn.malformed_retried ? true : false,
|
|
486
|
+
payload: response[-MALFORMED_TAIL_CHARS..] || response)
|
|
487
|
+
turn.buffer.slice!(generation.buffer_mark..)
|
|
488
|
+
if turn.malformed_retried
|
|
489
|
+
label = @client.respond_to?(:host_name) && @client.host_name ? @client.host_name : "llama.cpp"
|
|
490
|
+
raise LLM::MalformedGeneration.new("#{label}: #{reason}, again without the prompt cache; nothing was saved",
|
|
491
|
+
host: label)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
turn.malformed_retried = true
|
|
495
|
+
turn.uncached_next = true
|
|
496
|
+
emit(turn, type: :empty_answer_retry, iteration: turn.iteration, attempt: 1, of: 1,
|
|
497
|
+
finish_reason: generation.finish_reason, malformed: true)
|
|
498
|
+
:next
|
|
499
|
+
end
|
|
500
|
+
|
|
465
501
|
# A generation with no tool calls: a Qwen call left open is asked to be
|
|
466
502
|
# finished; an empty answer is asked again (EmptyAnswerRetry: not when
|
|
467
503
|
# a length stop came with the context full); queued input keeps the
|
|
@@ -612,8 +648,11 @@ module Samagotchi
|
|
|
612
648
|
end
|
|
613
649
|
|
|
614
650
|
# +sampling+: this request's (EmptyAnswerRetry#request_sampling).
|
|
615
|
-
|
|
651
|
+
# +cache_prompt+: false for a malformed generation's retry (only then sent).
|
|
652
|
+
def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil,
|
|
653
|
+
cache_prompt: true)
|
|
616
654
|
kwargs = {}
|
|
655
|
+
kwargs[:cache_prompt] = false unless cache_prompt
|
|
617
656
|
# Only a request with images names them: a text-only call is unchanged.
|
|
618
657
|
kwargs[:images] = images unless images.empty?
|
|
619
658
|
kwargs[:on_chunk] = on_chunk if on_chunk
|
|
@@ -213,7 +213,7 @@ module Samagotchi
|
|
|
213
213
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
214
214
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
215
215
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
216
|
-
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
216
|
+
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
217
217
|
# (the live row's params line and label, never sent back), an
|
|
218
218
|
# edit/write result's tool_diffs (never sent), and an empty-answer
|
|
219
219
|
# note's marker (TurnNote.empty, never sent).
|
|
@@ -225,6 +225,7 @@ module Samagotchi
|
|
|
225
225
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
226
226
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
227
227
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
228
|
+
message[:turn_start] = true if entry[:turn_start] == true
|
|
228
229
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
229
230
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
230
231
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
@@ -207,6 +207,15 @@ module Samagotchi
|
|
|
207
207
|
def summary = "unexpected response from host #{host}: #{detail}"
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
+
# A native generation that came out corrupt twice (KernelLoop: a tool
|
|
211
|
+
# call never closed, or a fresh thought header after the answer), the
|
|
212
|
+
# second time without the server's prompt cache. A poisoned llama.cpp
|
|
213
|
+
# prompt cache does this (ggml-org/llama.cpp#27148); the text is not
|
|
214
|
+
# an answer.
|
|
215
|
+
class MalformedGeneration < ProtocolError
|
|
216
|
+
def summary = "malformed generation from host #{host}: #{detail}"
|
|
217
|
+
end
|
|
218
|
+
|
|
210
219
|
# A request that kept failing on network errors until the retry budget
|
|
211
220
|
# ran out (or failed mid-stream, where a retry would repeat output).
|
|
212
221
|
class RetryExhausted < ConnectionError
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
30
|
question_relay: %i[id reason],
|
|
31
31
|
generation_cancelled: %i[iteration],
|
|
32
|
-
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
|
|
32
|
+
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by malformed]
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
# @param session_id [#call] the session the events are about (the
|
|
@@ -302,6 +302,8 @@ module Samagotchi
|
|
|
302
302
|
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
303
303
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
304
304
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
305
|
+
# ── Scripts: scripts/<file> into <bundle_dir>/scripts/ ────────────
|
|
306
|
+
scripts_for_provenance = install_scripts(manifest, normalized_dir, provenance)
|
|
305
307
|
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
306
308
|
warn_rules_requires_chi(manifest, incoming_rules)
|
|
307
309
|
|
|
@@ -417,7 +419,9 @@ module Samagotchi
|
|
|
417
419
|
plugin_file: plugin_file_for_provenance,
|
|
418
420
|
requires_chi: manifest.requires_chi,
|
|
419
421
|
needs: manifest.needs,
|
|
420
|
-
conflicts: @conflicts.keys
|
|
422
|
+
conflicts: @conflicts.keys,
|
|
423
|
+
scripts_files: scripts_for_provenance,
|
|
424
|
+
context_providers: manifest.context_providers
|
|
421
425
|
)
|
|
422
426
|
end
|
|
423
427
|
|
|
@@ -615,6 +619,44 @@ module Samagotchi
|
|
|
615
619
|
dest
|
|
616
620
|
end
|
|
617
621
|
|
|
622
|
+
# The manifest's scripts, copied into <bundle_dir>/scripts/ (the
|
|
623
|
+
# folder replaced: a script the new version dropped goes). A declared
|
|
624
|
+
# sha256 that differs warns, as a plugin's does.
|
|
625
|
+
# @return [Hash{String => String}] file name => installed path
|
|
626
|
+
def install_scripts(manifest, source_dir, provenance)
|
|
627
|
+
scripts = manifest&.scripts || {}
|
|
628
|
+
scripts.each_key do |file|
|
|
629
|
+
next if File.file?(File.join(source_dir, "scripts", file))
|
|
630
|
+
|
|
631
|
+
raise InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
|
|
632
|
+
end
|
|
633
|
+
return {} if @dry_run
|
|
634
|
+
|
|
635
|
+
unchanged = scripts.keys.select do |file|
|
|
636
|
+
dest = File.join(provenance.scripts_dir, file)
|
|
637
|
+
File.file?(dest) && FileUtils.identical?(File.join(source_dir, "scripts", file), dest)
|
|
638
|
+
end
|
|
639
|
+
FileUtils.rm_rf(provenance.scripts_dir)
|
|
640
|
+
return {} if scripts.empty?
|
|
641
|
+
|
|
642
|
+
FileUtils.mkdir_p(provenance.scripts_dir)
|
|
643
|
+
scripts.to_h do |file, declared|
|
|
644
|
+
dest = File.join(provenance.scripts_dir, file)
|
|
645
|
+
FileUtils.cp(File.join(source_dir, "scripts", file), dest)
|
|
646
|
+
@results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
|
|
647
|
+
elsif @upgrade && provenance.read then { status: "updated" }
|
|
648
|
+
else { status: "installed" }
|
|
649
|
+
end
|
|
650
|
+
FileUtils.chmod(0o755, dest)
|
|
651
|
+
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
652
|
+
expected = declared.to_s.delete_prefix("sha256:")
|
|
653
|
+
if @strict && !expected.empty? && actual != expected
|
|
654
|
+
@warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
655
|
+
end
|
|
656
|
+
[file, dest]
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
618
660
|
# A model overlay (<name>.<key>.md, docs/memory.md) loads only with
|
|
619
661
|
# its base, under the matching model: it gets no index line (every
|
|
620
662
|
# model would see it as a memory of its own). Its base is looked for
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "../context_providers"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module MemoryBundle
|
|
@@ -25,6 +26,15 @@ module Samagotchi
|
|
|
25
26
|
# why: reads PRs # optional
|
|
26
27
|
# hint: brew install gh # optional
|
|
27
28
|
# - jq # short form: just the command
|
|
29
|
+
# scripts: # optional — files in scripts/ a context
|
|
30
|
+
# pr_context.rb: sha256:… # provider's command runs (installed into
|
|
31
|
+
# # the bundle's own dir, scripts/)
|
|
32
|
+
# context_providers: # optional — URL → attached context source
|
|
33
|
+
# - match: '\Ahttps://…/pull/(\d+)' # (ContextProviders)
|
|
34
|
+
# name: 'pr-\1'
|
|
35
|
+
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
36
|
+
# why: GitHub PR # optional
|
|
37
|
+
# every_seconds: 300 # optional
|
|
28
38
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
29
39
|
# # shipped bundles it installs; its dir holds
|
|
30
40
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -33,12 +43,15 @@ module Samagotchi
|
|
|
33
43
|
|
|
34
44
|
# A plugin file is a plain .rb name in the bundle's top directory.
|
|
35
45
|
PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
|
|
46
|
+
# A script is a plain file name in the bundle's scripts/.
|
|
47
|
+
SCRIPT_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
|
|
36
48
|
# A need is a plain executable name: no path, no spaces.
|
|
37
49
|
NEED_COMMAND = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/
|
|
38
50
|
# A bundle name, as `chi bundle install <name>` takes a shipped one.
|
|
39
51
|
BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
40
52
|
|
|
41
|
-
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes
|
|
53
|
+
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
54
|
+
:scripts, :context_providers
|
|
42
55
|
|
|
43
56
|
def initialize(path:)
|
|
44
57
|
@path = path
|
|
@@ -53,6 +66,8 @@ module Samagotchi
|
|
|
53
66
|
@plugin = parse_plugin(raw["plugin"])
|
|
54
67
|
@requires_chi = parse_requires_chi(raw["requires_chi"])
|
|
55
68
|
@needs = self.class.parse_needs(raw["needs"])
|
|
69
|
+
@scripts = parse_scripts(raw["scripts"])
|
|
70
|
+
@context_providers = parse_context_providers(raw["context_providers"])
|
|
56
71
|
@includes = parse_includes(raw["includes"])
|
|
57
72
|
end
|
|
58
73
|
|
|
@@ -213,6 +228,27 @@ module Samagotchi
|
|
|
213
228
|
{ file: file, sha256: sha }
|
|
214
229
|
end
|
|
215
230
|
|
|
231
|
+
# scripts: {file => sha256} → the same, "sha256:" prefixed; {} when absent.
|
|
232
|
+
def parse_scripts(raw)
|
|
233
|
+
return {} if raw.nil?
|
|
234
|
+
raise ValidationError, "scripts: must be a mapping of file name to sha256" unless raw.is_a?(Hash)
|
|
235
|
+
|
|
236
|
+
raw.to_h do |file, sha|
|
|
237
|
+
file = file.to_s
|
|
238
|
+
raise ValidationError, "scripts: #{file.inspect} isn't a plain file name in scripts/" unless file.match?(SCRIPT_FILE)
|
|
239
|
+
|
|
240
|
+
sha = sha.to_s.strip
|
|
241
|
+
[file, sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"]
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Array<ContextProviders::Provider>]
|
|
246
|
+
def parse_context_providers(raw)
|
|
247
|
+
ContextProviders.parse_list(raw)
|
|
248
|
+
rescue ContextProviders::Invalid => e
|
|
249
|
+
raise ValidationError, e.message
|
|
250
|
+
end
|
|
251
|
+
|
|
216
252
|
# includes: → the names in order, duplicates dropped; [] when absent.
|
|
217
253
|
# A meta ships nothing of its own, so files, hooks or a plugin next
|
|
218
254
|
# to it is a ValidationError.
|
|
@@ -226,8 +262,8 @@ module Samagotchi
|
|
|
226
262
|
|
|
227
263
|
name
|
|
228
264
|
end.uniq
|
|
229
|
-
if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin)
|
|
230
|
-
raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks or
|
|
265
|
+
if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin || !@scripts.empty?)
|
|
266
|
+
raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks, plugin or scripts)"
|
|
231
267
|
end
|
|
232
268
|
|
|
233
269
|
names
|
|
@@ -39,6 +39,12 @@ module Samagotchi
|
|
|
39
39
|
File.join(@bundle_dir, "plugin")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# The installed scripts' dir (scripts/<file>): what a context
|
|
43
|
+
# provider's command finds as {bundle_dir}/scripts.
|
|
44
|
+
def scripts_dir
|
|
45
|
+
File.join(@bundle_dir, "scripts")
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
# The plugin file as installed, or nil when the bundle has none.
|
|
43
49
|
def plugin_path(data = read)
|
|
44
50
|
file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
|
|
@@ -129,8 +135,12 @@ module Samagotchi
|
|
|
129
135
|
# edits that conflict with it; their entries get conflict: true
|
|
130
136
|
# @param includes [Array<String>, nil] a profile's recorded members
|
|
131
137
|
# (MemoryBundle::Profile); nil for a plain bundle
|
|
138
|
+
# @param scripts_files [Hash] file name => installed script; the
|
|
139
|
+
# sha256 of each is recorded (ContextFetch checks it before a run)
|
|
140
|
+
# @param context_providers [Array<ContextProviders::Provider>]
|
|
132
141
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
133
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil
|
|
142
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
143
|
+
scripts_files: {}, context_providers: [])
|
|
134
144
|
FileUtils.mkdir_p(@bundle_dir)
|
|
135
145
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
136
146
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -241,6 +251,12 @@ module Samagotchi
|
|
|
241
251
|
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
242
252
|
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
243
253
|
manifest_data["includes"] = includes.map(&:to_s) if includes.is_a?(Array)
|
|
254
|
+
unless scripts_files.empty?
|
|
255
|
+
manifest_data["scripts"] = scripts_files.sort.to_h do |file, path|
|
|
256
|
+
[file.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.binread(path.to_s))}" }]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
244
260
|
|
|
245
261
|
write_manifest(manifest_data)
|
|
246
262
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../context_sources"
|
|
5
|
+
require_relative "../context_providers"
|
|
6
|
+
require_relative "../session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Plugin
|
|
10
|
+
# ctx.context (docs/plugins.md, Attached context): context a plugin
|
|
11
|
+
# attaches to its session (ContextSources), through an installed
|
|
12
|
+
# bundle's provider (a URL) or as its own command. In-process, like any
|
|
13
|
+
# plugin code: not gated as `chi context add --cmd` through execute is.
|
|
14
|
+
# The session's worker runs and absorbs it (ContextPoller).
|
|
15
|
+
class AttachedContext
|
|
16
|
+
# An attach that couldn't be done; the message says why.
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
# @param host [Host] session_id and state_dir
|
|
20
|
+
def initialize(host, bundle:)
|
|
21
|
+
@host = host
|
|
22
|
+
@bundle = bundle
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Attach a source to this session. +url+: through an installed
|
|
26
|
+
# bundle's provider (+name+ and +why+ replace the provider's); else
|
|
27
|
+
# +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
|
|
28
|
+
# already attached stays as it is. One the user removed from the
|
|
29
|
+
# session (its name, or its URL) isn't attached again (#declined?)
|
|
30
|
+
# until the user adds it again.
|
|
31
|
+
# @return [String, nil] the source's name; nil when declined
|
|
32
|
+
# @raise [Error]
|
|
33
|
+
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
|
|
34
|
+
own = location or raise Error, "this session has no id yet"
|
|
35
|
+
source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
|
|
36
|
+
return nil if own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
|
|
37
|
+
return source.name if own.source(source.name)
|
|
38
|
+
|
|
39
|
+
own.add(source.with(scope: own.scope))
|
|
40
|
+
source.name
|
|
41
|
+
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
42
|
+
raise Error, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether the user removed +name+ from this session (so #attach skips it).
|
|
46
|
+
def declined?(name)
|
|
47
|
+
own = location
|
|
48
|
+
own ? own.declined?(name) : false
|
|
49
|
+
rescue ContextSources::Invalid
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# This session's sources (its own, then its project's).
|
|
54
|
+
# @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:}
|
|
55
|
+
def list
|
|
56
|
+
id = @host.session_id.call
|
|
57
|
+
return [] unless id
|
|
58
|
+
|
|
59
|
+
session = Session.load(id, state_dir: state_dir)
|
|
60
|
+
ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
|
|
61
|
+
snapshot = attached.snapshot
|
|
62
|
+
{ name: attached.name, scope: attached.location.scope, why: attached.source.why,
|
|
63
|
+
hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
|
|
64
|
+
provider: attached.source.provider }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
71
|
+
|
|
72
|
+
def location
|
|
73
|
+
id = @host.session_id.call
|
|
74
|
+
id && ContextSources.session_location(id, state_dir: state_dir)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def from_url(url, name:, why:)
|
|
78
|
+
resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
|
|
79
|
+
source(name: name ? ContextSources.check_name!(name) : resolved.name, cmd: resolved.cmd, why: why || resolved.why,
|
|
80
|
+
hint: resolved.hint, every_seconds: resolved.every_seconds, provider: resolved.bundle)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def from_cmd(name:, cmd:, why:, hint:, every_seconds:)
|
|
84
|
+
raise Error, "give url: or cmd: (with name:)" if cmd.to_s.strip.empty?
|
|
85
|
+
|
|
86
|
+
source(name: ContextSources.check_name!(name), cmd: cmd.to_s, why: why, hint: hint,
|
|
87
|
+
every_seconds: ContextSources.check_every!(every_seconds), provider: nil)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def source(name:, cmd:, why:, hint:, every_seconds:, provider:)
|
|
91
|
+
ContextSources::Source.new(
|
|
92
|
+
name: name, cmd: cmd, every_seconds: every_seconds,
|
|
93
|
+
why: ContextSources.one_line(why, ContextSources::LINE_MAX_CHARS),
|
|
94
|
+
hint: ContextSources.one_line(hint, ContextSources::LINE_MAX_CHARS), scope: nil,
|
|
95
|
+
added_by: "plugin:#{@bundle}", created_at: Time.now.utc.iso8601, provider: provider
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "../guardrails/context"
|
|
|
7
7
|
require_relative "../idle_client"
|
|
8
8
|
require_relative "side_question"
|
|
9
9
|
require_relative "sessions"
|
|
10
|
+
require_relative "attached_context"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
module Plugin
|
|
@@ -98,6 +99,20 @@ module Samagotchi
|
|
|
98
99
|
# current_model_only names it
|
|
99
100
|
def model_key = @host.model_key&.call
|
|
100
101
|
|
|
102
|
+
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
103
|
+
def scratch? = !!@host.scratch&.call
|
|
104
|
+
|
|
105
|
+
# Whether the session is a delegate child (Session#delegate?): a task
|
|
106
|
+
# another session handed over, not the user's own.
|
|
107
|
+
def delegate?
|
|
108
|
+
id = session_id
|
|
109
|
+
return false unless id
|
|
110
|
+
|
|
111
|
+
Session.load(id, state_dir: @host.state_dir&.call || Session.default_state_dir).delegate?
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
101
116
|
# @return [String] the session's working directory
|
|
102
117
|
def cwd = @host.cwd.call || Dir.pwd
|
|
103
118
|
|
|
@@ -210,6 +225,13 @@ module Samagotchi
|
|
|
210
225
|
@sessions ||= Sessions.new(@host)
|
|
211
226
|
end
|
|
212
227
|
|
|
228
|
+
# This session's attached context: attach a URL or a command, list
|
|
229
|
+
# (AttachedContext).
|
|
230
|
+
# @return [AttachedContext]
|
|
231
|
+
def context
|
|
232
|
+
@context ||= AttachedContext.new(@host, bundle: @bundle)
|
|
233
|
+
end
|
|
234
|
+
|
|
213
235
|
# A single-select question through the question flow. Inside a
|
|
214
236
|
# stream hook's handler (generation_progress) it asks no one (nil).
|
|
215
237
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
@@ -20,9 +20,18 @@ module Samagotchi
|
|
|
20
20
|
# another slot (12 s);
|
|
21
21
|
# - once the warm-up is done, nothing is pinned: the server finds the
|
|
22
22
|
# state itself, and pinning to a slot it has since cleared skips its
|
|
23
|
-
# prompt cache (12 s again). #take_pin forgets the slot either way
|
|
23
|
+
# prompt cache (12 s again). #take_pin forgets the slot either way;
|
|
24
|
+
# - no warm-up when /slots says that slot is busy (Client#slot_status):
|
|
25
|
+
# another session took it (a delegate child, often), and llama.cpp
|
|
26
|
+
# defers a pinned request until the slot is free, so the warm-up and
|
|
27
|
+
# then this session's next turn would wait out that generation. The
|
|
28
|
+
# next request then goes unpinned, and the server's prompt cache
|
|
29
|
+
# restores the state. When /slots can't say, the warm-up runs.
|
|
24
30
|
class PromptWarmup
|
|
25
31
|
MODES = %w[auto off].freeze
|
|
32
|
+
# Seconds #take_pin waits for the busy-slot check (Client#slot_status
|
|
33
|
+
# gives up within 3 s).
|
|
34
|
+
DECIDE_TIMEOUT = 3
|
|
26
35
|
|
|
27
36
|
# Whether cache.warmup allows a warm-up (auto, the default; off, false,
|
|
28
37
|
# no or 0 turn it off; anything else warns once and is auto).
|
|
@@ -52,8 +61,15 @@ module Samagotchi
|
|
|
52
61
|
# nothing to pin the next request to)
|
|
53
62
|
# @return [Thread]
|
|
54
63
|
def start(client:, prompt:, model:, slot:, images: [])
|
|
64
|
+
# Whether the warm-up went to its slot (true) or was skipped: the
|
|
65
|
+
# first value pushed is the answer.
|
|
66
|
+
sent = Queue.new
|
|
55
67
|
thread = Thread.new do
|
|
56
68
|
Thread.current.report_on_exception = false
|
|
69
|
+
busy = slot.is_a?(Integer) && client.slot_status(slot, model: model) == :busy
|
|
70
|
+
sent << !busy
|
|
71
|
+
next skip(slot) if busy
|
|
72
|
+
|
|
57
73
|
result = client.warm_up(prompt, model: model, slot: slot, images: images)
|
|
58
74
|
Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
|
|
59
75
|
prefill_ms: result&.prompt_ms, chars: prompt.length)
|
|
@@ -61,25 +77,34 @@ module Samagotchi
|
|
|
61
77
|
rescue StandardError => e
|
|
62
78
|
Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
63
79
|
nil
|
|
80
|
+
ensure
|
|
81
|
+
sent << false
|
|
64
82
|
end
|
|
65
83
|
@mutex.synchronize do
|
|
66
84
|
@thread = thread
|
|
67
|
-
@state = { thread: thread, client: client, slot: slot }
|
|
85
|
+
@state = { thread: thread, client: client, slot: slot, sent: sent }
|
|
68
86
|
end
|
|
69
87
|
thread
|
|
70
88
|
end
|
|
71
89
|
|
|
72
90
|
# The slot the next request on +client+ must run on: the warm-up's,
|
|
73
|
-
# while it still runs there; nil otherwise (done, failed,
|
|
74
|
-
# another host). Called once per request; the first call
|
|
75
|
-
# warm-up, so no later request is ever pinned.
|
|
91
|
+
# while it still runs there; nil otherwise (done, failed, skipped,
|
|
92
|
+
# unpinned, or another host). Called once per request; the first call
|
|
93
|
+
# forgets the warm-up, so no later request is ever pinned. Waits for
|
|
94
|
+
# the busy-slot check (DECIDE_TIMEOUT at most).
|
|
76
95
|
def take_pin(client)
|
|
77
96
|
state = @mutex.synchronize { @state.tap { @state = nil } }
|
|
78
97
|
return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
|
|
98
|
+
return nil unless state[:sent].pop(timeout: DECIDE_TIMEOUT)
|
|
79
99
|
|
|
80
100
|
state[:thread].alive? ? state[:slot] : nil
|
|
81
101
|
end
|
|
82
102
|
|
|
103
|
+
private def skip(slot)
|
|
104
|
+
Log.info(:model, "warmup_skipped", slot: slot, why: "slot busy")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
83
108
|
# Whether a warm-up is running (specs, measurements).
|
|
84
109
|
def running?
|
|
85
110
|
thread = @mutex.synchronize { @thread }
|
|
@@ -180,9 +180,6 @@ module Samagotchi
|
|
|
180
180
|
true
|
|
181
181
|
end
|
|
182
182
|
|
|
183
|
-
# @return [Boolean] the pending question is a standing one
|
|
184
|
-
def standing? = @lock.synchronize { !@standing.nil? }
|
|
185
|
-
|
|
186
183
|
private def ask(fields, watch)
|
|
187
184
|
pending = publish(fields)
|
|
188
185
|
id = pending[:id]
|