silas 0.3.2 → 0.5.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -0
  3. data/README.md +12 -6
  4. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  5. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  6. data/app/jobs/silas/agent_loop_job.rb +2 -0
  7. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  8. data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
  9. data/app/models/silas/compaction.rb +32 -0
  10. data/app/models/silas/tool_invocation.rb +37 -3
  11. data/app/models/silas/turn.rb +7 -1
  12. data/app/views/silas/channel_mailer/approval.text.erb +2 -2
  13. data/app/views/silas/channels/approvals/show.html.erb +1 -1
  14. data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
  15. data/config/brakeman.ignore +11 -0
  16. data/config/routes.rb +2 -0
  17. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  18. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  19. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  20. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  21. data/lib/generators/silas/install/install_generator.rb +2 -1
  22. data/lib/generators/silas/install/templates/initializer.rb +1 -1
  23. data/lib/silas/{engines → adapters}/base.rb +12 -2
  24. data/lib/silas/adapters/ruby_llm.rb +221 -0
  25. data/lib/silas/channel.rb +35 -0
  26. data/lib/silas/chat.rb +2 -2
  27. data/lib/silas/compactor.rb +178 -0
  28. data/lib/silas/configuration.rb +40 -9
  29. data/lib/silas/delta_buffer.rb +3 -3
  30. data/lib/silas/deprecator.rb +16 -0
  31. data/lib/silas/engine.rb +15 -2
  32. data/lib/silas/eval/driver.rb +1 -1
  33. data/lib/silas/eval/dsl.rb +1 -1
  34. data/lib/silas/eval/scripted_engine.rb +3 -3
  35. data/lib/silas/inbox/delta_broadcaster.rb +1 -1
  36. data/lib/silas/instrumentation.rb +59 -0
  37. data/lib/silas/ledger.rb +14 -0
  38. data/lib/silas/log_subscriber.rb +83 -0
  39. data/lib/silas/message_builder.rb +19 -0
  40. data/lib/silas/registry.rb +5 -2
  41. data/lib/silas/schedule.rb +44 -15
  42. data/lib/silas/slack.rb +10 -7
  43. data/lib/silas/step_runner.rb +10 -2
  44. data/lib/silas/tools/ask_question.rb +26 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas/webhook.rb +47 -0
  47. data/lib/silas.rb +28 -14
  48. metadata +15 -3
  49. data/lib/silas/engines/ruby_llm.rb +0 -165
@@ -0,0 +1,72 @@
1
+ require "rails/generators"
2
+
3
+ module Silas
4
+ module Generators
5
+ # rails g silas:channel whatsapp
6
+ #
7
+ # A channel is two halves that must agree on one name, and hand-rolling
8
+ # them is where the mistakes live: inbound needs signature verification and
9
+ # a stable thread key, outbound needs the approval link to reach an
10
+ # operator. This scaffolds both, wired together, with the security
11
+ # decisions already made.
12
+ class ChannelGenerator < Rails::Generators::NamedBase
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Scaffold a Silas channel: outbound Channel class, inbound webhook controller, and its route."
16
+
17
+ # Channel identity is the filename (app/agent/channels/whatsapp.rb ->
18
+ # Agent::Channels::Whatsapp), and Registry#channels resolves it with
19
+ # `camelize`. A filename that isn't a snake_case identifier produces a
20
+ # constant Zeitwerk can't define, and the channel then fails at boot
21
+ # rather than here — so refuse it here, where the message is useful.
22
+ # (Rails' usual normalisation still applies first: `MsTeams` and
23
+ # `ms_teams` both land on ms_teams.)
24
+ def validate_name
25
+ return if file_name.match?(/\A[a-z_][a-z0-9_]*\z/)
26
+
27
+ raise Thor::Error, "#{file_name.inspect} is not a valid channel name — " \
28
+ "it must be lowercase words separated by underscores, " \
29
+ "starting with a letter (e.g. whatsapp, ms_teams)."
30
+ end
31
+
32
+ # Templates are .rb.tt (Rails' own convention): the .tt keeps ERB-bearing
33
+ # files out of the linter and off Zeitwerk's radar.
34
+ def create_channel
35
+ template "channel.rb.tt", "app/agent/channels/#{file_name}.rb"
36
+ end
37
+
38
+ # The webhook lives in the HOST app, not the engine: only the host knows
39
+ # the vendor's signature scheme and payload shape. The engine ships
40
+ # routes for Slack alone because it also ships Slack's verification.
41
+ def create_controller
42
+ template "controller.rb.tt", "app/controllers/agent/channels/#{file_name}_controller.rb"
43
+ end
44
+
45
+ def add_route
46
+ route %(post "/agent/channels/#{file_name}", to: "agent/channels/#{file_name}#create")
47
+ end
48
+
49
+ def show_next_steps
50
+ say <<~MSG, :green
51
+
52
+ Channel "#{file_name}" scaffolded:
53
+ app/agent/channels/#{file_name}.rb (outbound: answers + approvals)
54
+ app/controllers/agent/channels/#{file_name}_controller.rb (inbound: webhook)
55
+ config/routes.rb POST /agent/channels/#{file_name}
56
+
57
+ Next:
58
+ 1. Set the signing secret:
59
+ bin/rails credentials:edit -> silas:
60
+ #{file_name}:
61
+ signing_secret: ...
62
+ 2. Fill in the three TODOs — the vendor's signature scheme, how a
63
+ message maps to a thread key, and how to post a message back.
64
+ 3. Point the vendor's webhook at https://<your-host>/agent/channels/#{file_name}
65
+ 4. Restart: app/agent/ registers at boot.
66
+
67
+ Full contract and a worked example: docs/channels.md
68
+ MSG
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # <%= class_name %> channel — OUTBOUND delivery for sessions that started in
2
+ # <%= human_name %>. Inbound lives in
3
+ # app/controllers/agent/channels/<%= file_name %>_controller.rb.
4
+ #
5
+ # Identity is this filename: <%= file_name %>.rb -> Agent::Channels::<%= class_name %>,
6
+ # and the controller starts sessions with channel: "<%= file_name %>", which is how
7
+ # Silas finds this class again at delivery time. Delete this file to disable
8
+ # the channel.
9
+ #
10
+ # Both methods run OFF the durable loop, in ChannelDeliveryJob — so a transport
11
+ # outage retries the delivery without re-running a single tool or touching the
12
+ # ledger. Raising here is safe; it costs a retry, not a duplicate side effect.
13
+ class Agent::Channels::<%= class_name %> < Silas::Channel
14
+ # The agent's final answer for a turn.
15
+ def deliver_answer(session:, text:)
16
+ thread = session.metadata["<%= file_name %>"] || {}
17
+
18
+ # TODO: post `text` back to the conversation. `thread` is whatever the
19
+ # controller stashed in metadata (chat id, phone number, message id).
20
+ raise NotImplementedError,
21
+ "Agent::Channels::<%= class_name %>#deliver_answer: post #{text.inspect} to #{thread.inspect}"
22
+ end
23
+
24
+ # A tool has parked for human approval; the turn is holding zero compute
25
+ # until someone answers. `approval_url` mints a signed, expiring one-click
26
+ # link that works in any transport.
27
+ def deliver_approval(session:, invocation:)
28
+ approve = Silas::Channel.approval_url(invocation, :approve)
29
+ decline = Silas::Channel.approval_url(invocation, :decline)
30
+
31
+ # SECURITY: an approval must reach an OPERATOR, never whoever started the
32
+ # session — mailing the approve link to the customer who asked for the
33
+ # refund lets them approve it themselves. Deliver to your ops destination
34
+ # (a staff channel, an on-call number), and FAIL CLOSED when it isn't
35
+ # configured: no destination means no approval, not a silent one.
36
+ operator = Rails.application.credentials.dig(:silas, :<%= file_name %>, :operator)
37
+ if operator.blank?
38
+ Rails.logger.warn("[Silas] no credentials.silas.<%= file_name %>.operator — approval for " \
39
+ "invocation #{invocation.id} not delivered (won't send it to the requester).")
40
+ return
41
+ end
42
+
43
+ # TODO: send `approve` / `decline` to `operator`, with enough context to
44
+ # decide: invocation.tool_name and invocation.arguments.
45
+ raise NotImplementedError,
46
+ "Agent::Channels::<%= class_name %>#deliver_approval: send #{approve} / #{decline} to #{operator}"
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # <%= class_name %> channel — INBOUND webhook. Outbound delivery lives in
2
+ # app/agent/channels/<%= file_name %>.rb.
3
+ #
4
+ # Inbound is pure trigger reuse: verify the request is genuine, derive a stable
5
+ # thread key, and hand it to Channel.dispatch — which starts a session for a new
6
+ # thread and continues the existing one for a reply. Nothing here touches the
7
+ # durable loop.
8
+ class Agent::Channels::<%= class_name %>Controller < ActionController::Base
9
+ # A webhook carries no browser session, so no CSRF token can exist. The
10
+ # signature below IS the authentication — do not remove one without the other.
11
+ skip_forgery_protection
12
+
13
+ before_action :verify_webhook!
14
+
15
+ def create
16
+ # TODO: derive a STABLE thread key — the same conversation must produce the
17
+ # same key every time, or every message starts a new session. Use the
18
+ # vendor's conversation/thread id, never a per-message id.
19
+ thread_key = params[:conversation_id].to_s
20
+ text = params[:text].to_s
21
+
22
+ return head(:ok) if thread_key.blank? || text.blank?
23
+
24
+ Agent::Channels::<%= class_name %>.dispatch(
25
+ thread_key: thread_key,
26
+ input: text,
27
+ # Whatever deliver_answer needs to reply. Stored on the session, so keep
28
+ # it small and free of secrets — it is visible in the inbox.
29
+ metadata: { "<%= file_name %>" => { "conversation_id" => thread_key } }
30
+ )
31
+ head :ok
32
+ rescue Silas::TurnInProgressError
33
+ # One active turn per session is an invariant, not a queue: a reply that
34
+ # arrives mid-turn is dropped. Tell the user, or buffer it, if that matters
35
+ # for your transport.
36
+ head :ok
37
+ end
38
+
39
+ private
40
+
41
+ # Rejects anything not genuinely signed. Silas::Webhook.verify_hmac handles
42
+ # the parts that are identical everywhere — constant-time comparison, the
43
+ # replay window, and failing closed when no secret is configured; you supply
44
+ # the vendor's shape.
45
+ #
46
+ # TODO: match your vendor's scheme. The three that vary:
47
+ # payload what they sign. Slack signs "v0:#{timestamp}:#{body}"; GitHub
48
+ # and Shopify sign the raw body. It must be request.raw_post,
49
+ # never re-serialized params — different bytes, different HMAC.
50
+ # prefix what they put before the digest ("v0=", "sha256=", or "").
51
+ # digest :hex (nearly everyone) or :base64 (Shopify, Twilio).
52
+ def verify_webhook!
53
+ ok = Silas::Webhook.verify_hmac(
54
+ secret: Rails.application.credentials.dig(:silas, :<%= file_name %>, :signing_secret),
55
+ signature: request.headers["X-Signature"],
56
+ payload: request.raw_post,
57
+ # Omitting a timestamp disables replay protection: a captured request can
58
+ # then be re-sent forever. Pass the vendor's timestamp header if it sends
59
+ # one, and treat its absence as a reason to check the vendor's docs.
60
+ timestamp: request.headers["X-Signature-Timestamp"],
61
+ prefix: "sha256="
62
+ )
63
+ head(:unauthorized) unless ok
64
+ ok
65
+ end
66
+ end
@@ -109,7 +109,8 @@ module Silas
109
109
  6. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
110
110
  7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
111
111
  for Slack; route inbound mail to Silas::AgentMailbox for email.
112
- Delete app/agent/channels/{slack,email}.rb to disable.
112
+ Delete app/agent/channels/{slack,email}.rb to disable. Any other
113
+ transport: `bin/rails g silas:channel whatsapp`.
113
114
  8. Inbox + web chat: /silas/inbox, deny-by-default — uncomment
114
115
  config.inbox_auth in config/initializers/silas.rb to make it visible.
115
116
  9. Restart your server if it was running (app/agent/ registers at boot).
@@ -1,7 +1,7 @@
1
1
  Silas.configure do |config|
2
2
  # Inference engine: :ruby_llm (API key, any provider RubyLLM supports), or
3
3
  # any object responding to #execute_step. See silas/README.
4
- config.engine = :ruby_llm
4
+ config.adapter = :ruby_llm
5
5
 
6
6
  # Any model your installed ruby_llm's registry resolves (newer models may
7
7
  # need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
@@ -3,11 +3,11 @@ module Silas
3
3
  # set — consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
4
4
  # :message_start — once per model call (before_message)
5
5
  # :text_delta — { text: } chunks as the response streams
6
- # StepRunner coalesces :text_delta into "silas.delta" notifications (see
6
+ # StepRunner coalesces :text_delta into "delta.silas" notifications (see
7
7
  # DeltaBuffer); everything else is available to custom engines/hooks.
8
8
  Event = Data.define(:type, :payload)
9
9
 
10
- module Engines
10
+ module Adapters
11
11
  # The inference seam. An engine executes exactly ONE model call for a step
12
12
  # and reports what came back; the framework owns the loop, the ledger owns
13
13
  # tool execution.
@@ -25,4 +25,14 @@ module Silas
25
25
 
26
26
  ToolCall = Data.define(:id, :name, :arguments)
27
27
  end
28
+
29
+ # Renamed Engines:: -> Adapters:: in 0.4, removed in 2.0. Host apps subclass
30
+ # Adapters::Base for custom inference backends, so the old constant keeps
31
+ # resolving (with a warning) rather than blowing up on upgrade.
32
+ module Engines
33
+ def self.const_missing(name)
34
+ Silas.deprecator.warn("Silas::Engines::#{name} is deprecated; use Silas::Adapters::#{name}")
35
+ Silas::Adapters.const_get(name)
36
+ end
37
+ end
28
38
  end
@@ -0,0 +1,221 @@
1
+ module Silas
2
+ module Adapters
3
+ # The :ruby_llm adapter: ONE model call per step, streamed, with the tool
4
+ # calls handed back unexecuted.
5
+ #
6
+ # RubyLLM's `Chat#complete` runs the whole agentic loop — model, run tools,
7
+ # feed results back, model again. Silas needs a single move, because the
8
+ # step boundary IS the durability boundary (checkpoint, ledger, park). So
9
+ # Chat is used as the BUILDER it is — it owns model resolution, schema
10
+ # normalisation, system instructions and message construction — and the
11
+ # execution goes one layer down to `Provider#complete`, which is exactly
12
+ # what Chat itself calls for a single turn.
13
+ #
14
+ # Everything here is RubyLLM's public API: Chat's attr_readers (model,
15
+ # messages, tools, schema, tool_prefs), Provider.resolve, and
16
+ # Provider#complete. (Until 0.5 this used tool proxies that threw
17
+ # `RubyLLM::Tool::Halt` to abort the loop from inside — RubyLLM 2.0 removes
18
+ # Halt precisely because the loop became caller-controlled, so this binding
19
+ # is both simpler now and the forward-compatible one.)
20
+ class RubyLLM < Base
21
+ def execute_step(context, &on_event)
22
+ chat = build_chat(context)
23
+
24
+ response = Silas.instrument(:step,
25
+ turn_id: context[:turn]&.id,
26
+ index: context[:index],
27
+ model: context[:model]) do
28
+ if on_event
29
+ # Fires before the HTTP request, matching what RubyLLM's
30
+ # before_message callback did under streaming — but ours, and
31
+ # explicitly ordered rather than incidentally so.
32
+ on_event.call(Event.new(type: :message_start, payload: {}))
33
+ complete(chat) do |chunk|
34
+ # Chunks carrying tool-call fragments have nil/empty content —
35
+ # only text streams.
36
+ text = chunk.content
37
+ on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
38
+ end
39
+ else
40
+ complete(chat)
41
+ end
42
+ end
43
+
44
+ to_result(response, schema: chat.schema)
45
+ end
46
+
47
+ private
48
+
49
+ # One turn, tools advertised but never run. Streamed and sync return the
50
+ # same Message shape (the stream accumulator builds it), so there is no
51
+ # branch below this point.
52
+ def complete(chat, &block)
53
+ provider_for(chat.model).complete(
54
+ chat.messages,
55
+ tools: chat.tools,
56
+ tool_prefs: chat.tool_prefs,
57
+ temperature: nil,
58
+ model: chat.model,
59
+ schema: chat.schema,
60
+ &block
61
+ )
62
+ end
63
+
64
+ # Resolved from the model Chat already resolved, so the two can never
65
+ # disagree. Memoised per provider slug: the adapter instance is itself
66
+ # memoised on Silas (and dropped whenever config changes), and building a
67
+ # provider builds a Faraday connection — not something to redo per step.
68
+ # A benign race here costs one extra connection, never correctness.
69
+ def provider_for(model_info)
70
+ @providers ||= {}
71
+ @providers[model_info.provider] ||=
72
+ ::RubyLLM::Provider.resolve(model_info.provider).new(::RubyLLM.config)
73
+ end
74
+
75
+ def build_chat(context)
76
+ chat = begin
77
+ ::RubyLLM.chat(model: context[:model])
78
+ rescue ::RubyLLM::ModelNotFoundError
79
+ raise Silas::Error,
80
+ "Model #{context[:model].inspect} is not in ruby_llm's model registry. " \
81
+ "Newer models may need a registry refresh (`RubyLLM.models.refresh!`), " \
82
+ "or pick a registry-known model in config.default_model / agent.yml."
83
+ end
84
+ chat.with_instructions(context[:system]) if context[:system].present?
85
+ # agent.yml's final_answer schema: with_schema renders the provider's
86
+ # structured-output dialect. The response comes back as a JSON string
87
+ # (Chat#complete would have parsed it for us) — to_result does that.
88
+ chat.with_schema(context[:final_answer]) if context[:final_answer].present?
89
+ # with_tools (plural), not with_tool — 2.0 drops the singular form and
90
+ # the plural exists in both.
91
+ context[:tools].each { |definition| chat.with_tools(SchemaProxy.new(definition)) }
92
+
93
+ replay_history(chat, context[:messages])
94
+ chat
95
+ end
96
+
97
+ # Rebuild the provider conversation from Silas's canonical rows. The last
98
+ # user message is delivered via ask-equivalent add_message; the whole
99
+ # array goes to the provider on complete.
100
+ def replay_history(chat, messages)
101
+ i = 0
102
+ while i < messages.length
103
+ msg = messages[i]
104
+ case msg[:role]
105
+ when "user"
106
+ chat.add_message(role: :user, content: msg[:content])
107
+ i += 1
108
+ when "assistant"
109
+ chat.add_message(
110
+ role: :assistant,
111
+ content: text_from(msg[:content]),
112
+ tool_calls: tool_calls_from(msg[:content])
113
+ )
114
+ i += 1
115
+ when "tool"
116
+ # Anthropic requires every tool_result for one assistant turn to sit
117
+ # in a single user message. The model can emit parallel tool_use
118
+ # blocks, so batch all consecutive tool results into one Raw message
119
+ # (a one-element batch is the ordinary single-tool-call case).
120
+ first_id = msg[:tool_call_id]
121
+ blocks = []
122
+ while i < messages.length && messages[i][:role] == "tool"
123
+ t = messages[i]
124
+ blocks << {
125
+ type: "tool_result",
126
+ tool_use_id: t[:tool_call_id],
127
+ content: JSON.generate(t[:content])
128
+ }
129
+ i += 1
130
+ end
131
+ chat.add_message(role: :tool, tool_call_id: first_id,
132
+ content: ::RubyLLM::Content::Raw.new(blocks))
133
+ else
134
+ i += 1
135
+ end
136
+ end
137
+ end
138
+
139
+ def text_from(blocks)
140
+ Array(blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
141
+ end
142
+
143
+ def tool_calls_from(blocks)
144
+ calls = Array(blocks).select { |b| b["type"] == "tool_call" }
145
+ return nil if calls.empty?
146
+
147
+ calls.to_h do |b|
148
+ [ b["id"], ::RubyLLM::ToolCall.new(id: b["id"], name: b["name"], arguments: b["arguments"]) ]
149
+ end
150
+ end
151
+
152
+ def to_result(assistant, schema:)
153
+ blocks = []
154
+ content = structured_content(assistant, schema:)
155
+ if content.is_a?(Hash)
156
+ # with_schema active: persist the parsed payload as its own block type
157
+ # — content.to_s here would write Ruby's Hash#inspect string into the
158
+ # transcript as "text".
159
+ blocks << { "type" => "structured", "data" => content }
160
+ elsif content.to_s.present?
161
+ blocks << { "type" => "text", "text" => content.to_s }
162
+ end
163
+
164
+ tool_calls = (assistant.tool_calls || {}).values.map do |tc|
165
+ blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
166
+ "arguments" => tc.arguments || {} }
167
+ ToolCall.new(id: tc.id, name: tc.name, arguments: (tc.arguments || {}).stringify_keys)
168
+ end
169
+
170
+ Result.new(
171
+ blocks: blocks,
172
+ tool_calls: tool_calls,
173
+ stop_reason: tool_calls.any? ? "tool_use" : "end_turn",
174
+ usage: { input_tokens: assistant.tokens&.input, output_tokens: assistant.tokens&.output }
175
+ )
176
+ end
177
+
178
+ # Chat#complete normally JSON-parses a schema response before handing it
179
+ # back; calling the provider directly means we do it. A response that
180
+ # doesn't parse stays a string rather than raising — a malformed payload
181
+ # is the model's problem to see in the transcript, not a crash.
182
+ def structured_content(assistant, schema:)
183
+ content = assistant.content
184
+ return content unless schema && content.is_a?(String) && !assistant.tool_call?
185
+
186
+ begin
187
+ JSON.parse(content)
188
+ rescue JSON::ParserError
189
+ content
190
+ end
191
+ end
192
+
193
+ # Carries a Silas tool's schema to the provider. Subclasses RubyLLM::Tool
194
+ # so it satisfies whatever the provider tool-renderers read (today: name,
195
+ # description, params_schema, parameters, provider_params) without Silas
196
+ # having to track that list.
197
+ #
198
+ # It has no #execute on purpose. Nothing calls it — the ledger owns tool
199
+ # execution — and RubyLLM::Tool#execute raises NotImplementedError, so if
200
+ # anything ever did, it fails loudly instead of feeding the model a
201
+ # sentinel.
202
+ class SchemaProxy < ::RubyLLM::Tool
203
+ def initialize(definition)
204
+ super()
205
+ @definition = definition
206
+ end
207
+
208
+ def name = @definition["name"]
209
+ def description = @definition["description"]
210
+
211
+ # RubyLLM 1.x reads params_schema; 2.0 renames it parameters_schema
212
+ # (alongside parameters -> declared_parameters and provider_params ->
213
+ # provider_options, which we inherit rather than override). Answering to
214
+ # both is two lines and makes the proxy version-agnostic — confirmed
215
+ # against ruby_llm edge by the CI canary.
216
+ def params_schema = @definition["input_schema"]
217
+ def parameters_schema = @definition["input_schema"]
218
+ end
219
+ end
220
+ end
221
+ end
data/lib/silas/channel.rb CHANGED
@@ -57,6 +57,34 @@ module Silas
57
57
  Rails.application.message_verifier(TOKEN_PURPOSE)
58
58
  end
59
59
 
60
+ # A full one-click approve/decline URL for ANY transport — the signed token
61
+ # is the credential, so the link works in a WhatsApp message, a Discord
62
+ # embed, or an SMS exactly as it does in email.
63
+ #
64
+ # Built from the engine's own route set plus the discovered mount point,
65
+ # because a channel runs in a delivery job with no routing scope. The host
66
+ # is required and never guessed: a hostless approval link is a dead link,
67
+ # so this raises with the fix rather than shipping one.
68
+ def self.approval_url(invocation, action, host: nil)
69
+ options = default_url_options.merge(host: host || default_url_options[:host])
70
+ if options[:host].blank?
71
+ raise Error, "Silas::Channel.approval_url needs a host. Set " \
72
+ "config.action_mailer.default_url_options = { host: \"example.com\" } " \
73
+ "(or Rails.application.routes.default_url_options), or pass host:."
74
+ end
75
+
76
+ Silas::Engine.routes.url_helpers.channels_approval_url(
77
+ token: token_for(invocation, action),
78
+ script_name: Silas::Inbox.mount_path, **options
79
+ )
80
+ end
81
+
82
+ def self.default_url_options
83
+ mailer = Rails.application.config.action_mailer.default_url_options || {}
84
+ Rails.application.routes.default_url_options.merge(mailer)
85
+ end
86
+ private_class_method :default_url_options
87
+
60
88
  # --- outbound interface (subclasses implement) ---
61
89
  def deliver_answer(session:, text:)
62
90
  raise NotImplementedError, "#{self.class}#deliver_answer"
@@ -65,5 +93,12 @@ module Silas
65
93
  def deliver_approval(session:, invocation:)
66
94
  raise NotImplementedError, "#{self.class}#deliver_approval"
67
95
  end
96
+
97
+ # OPTIONAL: ask_question parks ping this instead of deliver_approval —
98
+ # define it on transports that can collect free text (the question is
99
+ # invocation.arguments["question"]; settle with invocation.answer!).
100
+ # Channels without it are simply not pinged; the question waits in the
101
+ # inbox. Deliberately NOT declared here raising NotImplementedError:
102
+ # respond_to? is the capability check.
68
103
  end
69
104
  end
data/lib/silas/chat.rb CHANGED
@@ -59,11 +59,11 @@ module Silas
59
59
  end
60
60
 
61
61
  # The REPL runs inline, in the same process as the loop — so it hears the
62
- # "silas.delta" notifications and prints tokens as they arrive. Filtered by
62
+ # "delta.silas" notifications and prints tokens as they arrive. Filtered by
63
63
  # session id: notifications are process-global.
64
64
  def with_delta_stream
65
65
  @live = {}
66
- subscription = ActiveSupport::Notifications.subscribe("silas.delta") do |*args|
66
+ subscription = ActiveSupport::Notifications.subscribe("delta.silas") do |*args|
67
67
  payload = args.last
68
68
  print_delta(payload) if @session && payload[:session_id] == @session.id
69
69
  end