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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +7 -1
  7. data/docs/context.md +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/plugins.md +77 -1
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +9 -2
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  20. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +8 -1
  28. data/lib/samagotchi/child_reports.rb +212 -0
  29. data/lib/samagotchi/client.rb +35 -4
  30. data/lib/samagotchi/config.rb +11 -0
  31. data/lib/samagotchi/context_absorber.rb +179 -0
  32. data/lib/samagotchi/context_command.rb +473 -0
  33. data/lib/samagotchi/context_fetch.rb +226 -0
  34. data/lib/samagotchi/context_note.rb +11 -5
  35. data/lib/samagotchi/context_poller.rb +100 -0
  36. data/lib/samagotchi/context_providers.rb +159 -0
  37. data/lib/samagotchi/context_sources.rb +452 -0
  38. data/lib/samagotchi/engine.rb +12 -5
  39. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  41. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  42. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  43. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  44. data/lib/samagotchi/idle_recap.rb +4 -0
  45. data/lib/samagotchi/kernel_loop.rb +44 -5
  46. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  47. data/lib/samagotchi/llm/errors.rb +9 -0
  48. data/lib/samagotchi/log_line.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +1 -1
  50. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  51. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  52. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  53. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  54. data/lib/samagotchi/plugin/context.rb +22 -0
  55. data/lib/samagotchi/prompt_warmup.rb +30 -5
  56. data/lib/samagotchi/question_desk.rb +0 -3
  57. data/lib/samagotchi/reply_wait.rb +39 -8
  58. data/lib/samagotchi/session.rb +30 -7
  59. data/lib/samagotchi/session_commands.rb +18 -1
  60. data/lib/samagotchi/session_inbox.rb +34 -1
  61. data/lib/samagotchi/session_manager.rb +37 -5
  62. data/lib/samagotchi/session_retention.rb +3 -0
  63. data/lib/samagotchi/steer.rb +48 -7
  64. data/lib/samagotchi/system_prompt.rb +1 -1
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
  66. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  67. data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
  68. data/lib/samagotchi/terminal_ui.rb +9 -3
  69. data/lib/samagotchi/tool_activity.rb +8 -0
  70. data/lib/samagotchi/tool_call_parser.rb +40 -0
  71. data/lib/samagotchi/tool_declarations.rb +28 -7
  72. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  73. data/lib/samagotchi/tools/builtins.rb +5 -0
  74. data/lib/samagotchi/tools/context_read.rb +163 -0
  75. data/lib/samagotchi/tools/delegate.rb +13 -4
  76. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  77. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  78. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  79. data/lib/samagotchi/tools/task_wait.rb +2 -2
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +117 -3
  82. data/lib/samagotchi/web/public/app.js +52 -14
  83. data/lib/samagotchi/web/public/composer_size.js +7 -6
  84. data/lib/samagotchi/web/public/context_chips.js +285 -0
  85. data/lib/samagotchi/web/public/data.js +21 -0
  86. data/lib/samagotchi/web/public/format.js +8 -1
  87. data/lib/samagotchi/web/public/index.html +49 -3
  88. data/lib/samagotchi/web/public/notify.js +6 -2
  89. data/lib/samagotchi/web/public/timing.js +7 -3
  90. data/lib/samagotchi/web/public/turn_events.js +34 -10
  91. data/lib/samagotchi/web/public/turn_view.js +26 -4
  92. data/lib/samagotchi/worker.rb +343 -8
  93. 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
- @scopes = picked.empty? ? SCOPES : picked
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, keyword_init: true)
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
- def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
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
@@ -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 plugin)"
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, unpinned, or
74
- # another host). Called once per request; the first call forgets the
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]