insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Insika
|
|
9
|
+
module Channels
|
|
10
|
+
# The channel for an adopter who ALREADY owns a messaging integration
|
|
11
|
+
# (RFC-0011 §6). A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
|
|
12
|
+
# engine for the TURN, not for the platform. Two routes and an envelope —
|
|
13
|
+
#
|
|
14
|
+
# consumer --POST /channels/relay/events--> engine acked now, never the reply
|
|
15
|
+
# consumer <--POST <deliver_url>---------- engine the reply, when there is one
|
|
16
|
+
#
|
|
17
|
+
# — and everything platform-shaped stays theirs: the 24-hour window, template
|
|
18
|
+
# approval, media, read receipts, and how markdown becomes WhatsApp formatting
|
|
19
|
+
# (§6.3). That is the promise, not the limitation: an integration someone has
|
|
20
|
+
# already tuned for years does not have to move for them to adopt the engine.
|
|
21
|
+
# A relay that starts growing template logic has stopped being a relay.
|
|
22
|
+
#
|
|
23
|
+
# It is also the cheapest possible Shape B, which is why it is built first:
|
|
24
|
+
# both ends are ours, so there is no third-party signature scheme and no
|
|
25
|
+
# rendering to get wrong at the same time as the durability. What it DOES
|
|
26
|
+
# exercise — the outbox, the claim, bounded retry, inbound dedup — is what
|
|
27
|
+
# Slack and native WhatsApp inherit untouched.
|
|
28
|
+
#
|
|
29
|
+
# R1/R2 hold: this object translates and authenticates, and does nothing else.
|
|
30
|
+
# No Executor, no store, no RubyLLM; it may refuse a request, never grant a
|
|
31
|
+
# capability.
|
|
32
|
+
class Relay
|
|
33
|
+
DEFAULT_ID = "relay"
|
|
34
|
+
DEFAULT_TIMEOUT = 10
|
|
35
|
+
|
|
36
|
+
attr_reader :id
|
|
37
|
+
|
|
38
|
+
# The bundled relay as an operator configures it: three env vars, of which the
|
|
39
|
+
# token is the SWITCH — no token, no channel, so there is no way to end up with
|
|
40
|
+
# this route mounted and open. -> Relay | nil.
|
|
41
|
+
#
|
|
42
|
+
# Shared by every composition root on purpose: the DSL front door has to reach
|
|
43
|
+
# the same feature as `config.ru`, or the docs are true of only one of them.
|
|
44
|
+
def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
|
|
45
|
+
token = Insika::EnvSchema.read("INSIKA_RELAY_TOKEN", env)
|
|
46
|
+
return nil unless Insika::EnvSchema.present?(token)
|
|
47
|
+
|
|
48
|
+
new(inbound_token: token,
|
|
49
|
+
deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
|
|
50
|
+
deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
|
|
51
|
+
http: http, allow_http: allow_http, allow_private: allow_private)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# inbound_token: shared secret the consumer sends us (Bearer). Blank ->
|
|
55
|
+
# the channel answers :disabled to every request, fail-closed
|
|
56
|
+
# by construction rather than open by omission.
|
|
57
|
+
# deliver_url: where the reply goes. Blank -> nothing is ever delivered
|
|
58
|
+
# (the channel still accepts inbound; the outbox records the
|
|
59
|
+
# reply and the delivery fails loudly instead of silently).
|
|
60
|
+
# deliver_token: Bearer we send THEM. Optional: a consumer on a private
|
|
61
|
+
# network may authenticate us another way.
|
|
62
|
+
def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
|
|
63
|
+
id: DEFAULT_ID, allow_http: false, allow_private: false,
|
|
64
|
+
timeout: DEFAULT_TIMEOUT)
|
|
65
|
+
@id = id.to_s
|
|
66
|
+
@inbound_token = inbound_token.to_s
|
|
67
|
+
@deliver_url = deliver_url.to_s
|
|
68
|
+
@deliver_token = deliver_token.to_s
|
|
69
|
+
@http = http || Insika::HttpClient.new
|
|
70
|
+
@allow_http = allow_http
|
|
71
|
+
@allow_private = allow_private
|
|
72
|
+
@timeout = timeout
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
|
|
76
|
+
# RFC sketched one): a status code is the transport's vocabulary, and keeping
|
|
77
|
+
# it out of here is what lets this class be tested without Rack and read
|
|
78
|
+
# without knowing HTTP.
|
|
79
|
+
def authenticate(req)
|
|
80
|
+
return :disabled if @inbound_token.empty?
|
|
81
|
+
|
|
82
|
+
provided = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer (.+)\z/, 1]
|
|
83
|
+
return :unauthorized if provided.nil?
|
|
84
|
+
|
|
85
|
+
secure_compare(@inbound_token, provided) ? :ok : :unauthorized
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Inbound envelope -> the fields the mount turns into a `:send_message`.
|
|
89
|
+
# STRING keys in, because the consumer's `vars` are arbitrary data keys.
|
|
90
|
+
#
|
|
91
|
+
# { "agent": "support", "external_id": "5511999998888",
|
|
92
|
+
# "event_id": "wamid.HBg…", "message": "queria saber do pedido",
|
|
93
|
+
# "vars": { … } }
|
|
94
|
+
def parse(_req, body:)
|
|
95
|
+
body = body.is_a?(Hash) ? body : {}
|
|
96
|
+
agent = string(body["agent"])
|
|
97
|
+
external_id = string(body["external_id"])
|
|
98
|
+
message = string(body["message"])
|
|
99
|
+
|
|
100
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
101
|
+
raise Insika::ValidationError, "external_id is required" if external_id.empty?
|
|
102
|
+
raise Insika::ValidationError, "message is required" if message.strip.empty?
|
|
103
|
+
|
|
104
|
+
vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
|
|
105
|
+
{ agent: agent, external_id: external_id, message: message,
|
|
106
|
+
event_id: presence(body["event_id"]), vars: vars }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# RFC-0011 §4.3 — the engine namespaces the platform's conversation key, so a
|
|
110
|
+
# Slack channel id and a phone number can never collide, an operator can see
|
|
111
|
+
# where a conversation came from, and an id minted for one channel cannot be
|
|
112
|
+
# used to read another's session.
|
|
113
|
+
def session_id_for(external_id) = "#{@id}:#{external_id}"
|
|
114
|
+
|
|
115
|
+
# The reverse: what the consumer called this conversation. Reads off the
|
|
116
|
+
# session id so a delivery needs no extra state.
|
|
117
|
+
def external_id_from(session_id)
|
|
118
|
+
s = session_id.to_s
|
|
119
|
+
s.start_with?("#{@id}:") ? s.delete_prefix("#{@id}:") : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hands ONE reply to the consumer's callback. -> the HTTP status (the
|
|
123
|
+
# dispatcher decides what 2xx means); raises DeliveryError when the request
|
|
124
|
+
# could not be made at all.
|
|
125
|
+
#
|
|
126
|
+
# `X-Insika-Delivery` is the outbox id: a stable idempotency key, so a
|
|
127
|
+
# consumer that receives the same delivery twice (we retried after a timeout
|
|
128
|
+
# that actually landed) can drop the second one.
|
|
129
|
+
def deliver(payload, to:, delivery_id: nil)
|
|
130
|
+
raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
|
|
131
|
+
|
|
132
|
+
if (reason = egress_violation)
|
|
133
|
+
raise Insika::DeliveryError, "egress blocked for deliver_url: #{reason}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
response = @http.request(method: :post, url: @deliver_url, timeout: @timeout,
|
|
137
|
+
headers: headers(delivery_id),
|
|
138
|
+
body: JSON.generate(payload.merge("external_id" => to.to_s)))
|
|
139
|
+
response[:status].to_i
|
|
140
|
+
rescue Insika::DeliveryError
|
|
141
|
+
raise
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
raise Insika::DeliveryError, "#{e.class}: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Resolved on EVERY call, not once at boot: a hostname that answered a public
|
|
149
|
+
# address yesterday can answer 169.254.169.254 today, and this POST carries
|
|
150
|
+
# the customer's conversation.
|
|
151
|
+
def egress_violation
|
|
152
|
+
Insika::EgressGuard.violation(@deliver_url, allow_http: @allow_http,
|
|
153
|
+
allow_private: @allow_private,
|
|
154
|
+
host_allowlist: [URI.parse(@deliver_url).host].compact)
|
|
155
|
+
rescue URI::InvalidURIError
|
|
156
|
+
"invalid URL"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def headers(delivery_id)
|
|
160
|
+
h = { "content-type" => "application/json" }
|
|
161
|
+
h["authorization"] = "Bearer #{@deliver_token}" unless @deliver_token.empty?
|
|
162
|
+
h["x-insika-delivery"] = delivery_id.to_s if delivery_id
|
|
163
|
+
h
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Constant time, and length-safe: comparing the digests means an attacker
|
|
167
|
+
# learns nothing from how long the check took, not even the token's length.
|
|
168
|
+
# OpenSSL rather than Rack::Utils so the engine's lib/ keeps no web framework
|
|
169
|
+
# in its load path.
|
|
170
|
+
def secure_compare(a, b)
|
|
171
|
+
OpenSSL.fixed_length_secure_compare(Digest::SHA256.digest(a), Digest::SHA256.digest(b))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def string(value) = value.to_s
|
|
175
|
+
def presence(value) = Insika::Coercion.presence(value)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Insika web widget (RFC-0011 §5.2).
|
|
2
|
+
//
|
|
3
|
+
// One <script> tag, no framework, no build step, no dependency:
|
|
4
|
+
//
|
|
5
|
+
// <script src="https://agents.example.com/channels/web/asset/widget.js"
|
|
6
|
+
// data-agent="support" data-title="Ask us anything" defer></script>
|
|
7
|
+
//
|
|
8
|
+
// Everything it needs it reads off its own tag: the agent, the chrome text, and
|
|
9
|
+
// the engine's base URL (derived from this file's own src, so an adopter never
|
|
10
|
+
// configures a host twice). Theming is a block of CSS custom properties — set
|
|
11
|
+
// them on :root and they win, because they are declared on the widget root with
|
|
12
|
+
// var(..., fallback).
|
|
13
|
+
//
|
|
14
|
+
// The one design decision worth stating: a message POST is NEVER retried. It is
|
|
15
|
+
// the request that runs a turn, so re-sending it costs a second LLM call and can
|
|
16
|
+
// put a second answer in front of the customer. A dropped stream shows what
|
|
17
|
+
// arrived plus a failure note, and the person decides whether to ask again. The
|
|
18
|
+
// backoff below is for minting a session — the only call here that is safe to
|
|
19
|
+
// repeat.
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
var script = document.currentScript;
|
|
24
|
+
if (!script) return;
|
|
25
|
+
|
|
26
|
+
var base = new URL(script.src, window.location.href).href.replace(/\/channels\/[^/]+\/asset\/[^/]*$/, "");
|
|
27
|
+
var channel = (script.src.match(/\/channels\/([^/]+)\/asset\//) || [])[1] || "web";
|
|
28
|
+
var agent = script.dataset.agent || "";
|
|
29
|
+
var title = script.dataset.title || "Chat";
|
|
30
|
+
var placeholder = script.dataset.placeholder || "Type a message…";
|
|
31
|
+
var greeting = script.dataset.greeting || "";
|
|
32
|
+
var storageKey = "insika:" + channel + ":" + agent + ":session";
|
|
33
|
+
|
|
34
|
+
if (!agent) {
|
|
35
|
+
console.error("[insika] the widget needs data-agent on its <script> tag");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- chrome ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
var CSS = [
|
|
42
|
+
".insika-w{position:fixed;right:var(--insika-offset,20px);bottom:var(--insika-offset,20px);z-index:2147483000;",
|
|
43
|
+
"font-family:var(--insika-font,system-ui,-apple-system,Segoe UI,Roboto,sans-serif);font-size:15px;line-height:1.5}",
|
|
44
|
+
".insika-b{width:56px;height:56px;border-radius:50%;border:0;cursor:pointer;display:block;margin-left:auto;",
|
|
45
|
+
"background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff);font-size:24px;",
|
|
46
|
+
"box-shadow:0 6px 24px rgba(0,0,0,.22)}",
|
|
47
|
+
".insika-p{display:none;flex-direction:column;width:min(380px,calc(100vw - 40px));height:min(560px,calc(100vh - 120px));",
|
|
48
|
+
"margin-bottom:12px;border-radius:14px;overflow:hidden;background:var(--insika-bg,#fff);color:var(--insika-fg,#111827);",
|
|
49
|
+
"border:1px solid var(--insika-border,rgba(0,0,0,.12));box-shadow:0 12px 48px rgba(0,0,0,.24)}",
|
|
50
|
+
".insika-w.open .insika-p{display:flex}",
|
|
51
|
+
".insika-h{padding:12px 14px;font-weight:600;background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff);",
|
|
52
|
+
"display:flex;align-items:center;justify-content:space-between}",
|
|
53
|
+
".insika-x{background:none;border:0;color:inherit;font-size:20px;cursor:pointer;line-height:1;padding:0 2px}",
|
|
54
|
+
".insika-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}",
|
|
55
|
+
".insika-m{max-width:85%;padding:9px 12px;border-radius:12px;white-space:pre-wrap;overflow-wrap:anywhere}",
|
|
56
|
+
".insika-m.user{align-self:flex-end;background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff)}",
|
|
57
|
+
".insika-m.agent{align-self:flex-start;background:var(--insika-muted,#f3f4f6);color:var(--insika-fg,#111827)}",
|
|
58
|
+
".insika-m.note{align-self:center;font-size:13px;opacity:.7;background:none;padding:2px}",
|
|
59
|
+
".insika-f{display:flex;gap:8px;padding:10px;border-top:1px solid var(--insika-border,rgba(0,0,0,.12))}",
|
|
60
|
+
".insika-f input{flex:1;min-width:0;padding:9px 11px;border-radius:9px;font:inherit;color:inherit;background:transparent;",
|
|
61
|
+
"border:1px solid var(--insika-border,rgba(0,0,0,.18))}",
|
|
62
|
+
".insika-f button{padding:9px 14px;border:0;border-radius:9px;cursor:pointer;font:inherit;",
|
|
63
|
+
"background:var(--insika-accent,#1f2937);color:var(--insika-on-accent,#fff)}",
|
|
64
|
+
".insika-f button[disabled]{opacity:.5;cursor:default}"
|
|
65
|
+
].join("");
|
|
66
|
+
|
|
67
|
+
var style = document.createElement("style");
|
|
68
|
+
style.textContent = CSS;
|
|
69
|
+
document.head.appendChild(style);
|
|
70
|
+
|
|
71
|
+
var root = el("div", "insika-w");
|
|
72
|
+
var panel = el("div", "insika-p");
|
|
73
|
+
var header = el("div", "insika-h");
|
|
74
|
+
var log = el("div", "insika-log");
|
|
75
|
+
var form = document.createElement("form");
|
|
76
|
+
var input = document.createElement("input");
|
|
77
|
+
var send = document.createElement("button");
|
|
78
|
+
var bubble = el("button", "insika-b");
|
|
79
|
+
|
|
80
|
+
header.appendChild(text("span", title));
|
|
81
|
+
var close = el("button", "insika-x");
|
|
82
|
+
close.type = "button";
|
|
83
|
+
close.textContent = "×";
|
|
84
|
+
close.setAttribute("aria-label", "Close");
|
|
85
|
+
header.appendChild(close);
|
|
86
|
+
|
|
87
|
+
form.className = "insika-f";
|
|
88
|
+
input.type = "text";
|
|
89
|
+
input.placeholder = placeholder;
|
|
90
|
+
input.setAttribute("aria-label", placeholder);
|
|
91
|
+
send.type = "submit";
|
|
92
|
+
send.textContent = "Send";
|
|
93
|
+
form.appendChild(input);
|
|
94
|
+
form.appendChild(send);
|
|
95
|
+
|
|
96
|
+
panel.appendChild(header);
|
|
97
|
+
panel.appendChild(log);
|
|
98
|
+
panel.appendChild(form);
|
|
99
|
+
|
|
100
|
+
bubble.type = "button";
|
|
101
|
+
bubble.textContent = "💬";
|
|
102
|
+
bubble.setAttribute("aria-label", title);
|
|
103
|
+
|
|
104
|
+
root.appendChild(panel);
|
|
105
|
+
root.appendChild(bubble);
|
|
106
|
+
document.body.appendChild(root);
|
|
107
|
+
|
|
108
|
+
if (greeting) say("agent", greeting);
|
|
109
|
+
|
|
110
|
+
bubble.addEventListener("click", toggle);
|
|
111
|
+
close.addEventListener("click", toggle);
|
|
112
|
+
form.addEventListener("submit", function (e) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
var value = input.value.trim();
|
|
115
|
+
if (!value || busy) return;
|
|
116
|
+
input.value = "";
|
|
117
|
+
ask(value);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function toggle() {
|
|
121
|
+
root.classList.toggle("open");
|
|
122
|
+
if (root.classList.contains("open")) input.focus();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- the turn -------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
var busy = false;
|
|
128
|
+
|
|
129
|
+
function ask(message) {
|
|
130
|
+
busy = true;
|
|
131
|
+
send.disabled = true;
|
|
132
|
+
say("user", message);
|
|
133
|
+
|
|
134
|
+
var bubbleEl = null;
|
|
135
|
+
var note = null;
|
|
136
|
+
|
|
137
|
+
session()
|
|
138
|
+
.then(function (id) {
|
|
139
|
+
return fetch(base + "/channels/" + channel + "/messages", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ agent: agent, session_id: id, message: message })
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
.then(function (res) {
|
|
146
|
+
// An unknown session id (the store was wiped, the id expired) is the one
|
|
147
|
+
// recoverable failure: drop ours and let the next message mint a new one.
|
|
148
|
+
if (res.status === 404) {
|
|
149
|
+
forget();
|
|
150
|
+
throw new Error("session expired — please send that again");
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok || !res.body) throw new Error("the assistant is unavailable right now");
|
|
153
|
+
return read(res.body.getReader(), function (event, data) {
|
|
154
|
+
if (event === "delta" && data.delta) {
|
|
155
|
+
if (note) { note.remove(); note = null; }
|
|
156
|
+
if (!bubbleEl) bubbleEl = say("agent", "");
|
|
157
|
+
bubbleEl.textContent += data.delta;
|
|
158
|
+
log.scrollTop = log.scrollHeight;
|
|
159
|
+
} else if (event === "working" && !bubbleEl && !note) {
|
|
160
|
+
note = say("note", "working…");
|
|
161
|
+
} else if (event === "error") {
|
|
162
|
+
if (note) { note.remove(); note = null; }
|
|
163
|
+
throw new Error(data.message || "something went wrong");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
})
|
|
167
|
+
.catch(function (err) {
|
|
168
|
+
if (note) note.remove();
|
|
169
|
+
say("note", err.message || "something went wrong");
|
|
170
|
+
})
|
|
171
|
+
.finally(function () {
|
|
172
|
+
busy = false;
|
|
173
|
+
send.disabled = false;
|
|
174
|
+
input.focus();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The engine issues the session id; we only ever store the one it gave us
|
|
179
|
+
// (RFC-0011 §4.3 — a client that proposes its own id on a public endpoint is
|
|
180
|
+
// one enumeration away from reading someone else's conversation).
|
|
181
|
+
function session() {
|
|
182
|
+
var saved = remembered;
|
|
183
|
+
if (saved) return Promise.resolve(saved);
|
|
184
|
+
|
|
185
|
+
return mint(0).then(function (id) {
|
|
186
|
+
remember(id);
|
|
187
|
+
return id;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// localStorage THROWS (not returns null) in a sandboxed iframe, in Safari's
|
|
192
|
+
// private mode and wherever site data is blocked. Reading it unguarded would
|
|
193
|
+
// throw out of `ask` before the .catch is attached, leaving the send button
|
|
194
|
+
// disabled forever. So the conversation degrades to in-memory instead: it lives
|
|
195
|
+
// for the page, which is worse than remembering and much better than frozen.
|
|
196
|
+
var remembered = read_storage();
|
|
197
|
+
|
|
198
|
+
function read_storage() {
|
|
199
|
+
try { return localStorage.getItem(storageKey); } catch (e) { return null; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function remember(id) {
|
|
203
|
+
remembered = id;
|
|
204
|
+
try { localStorage.setItem(storageKey, id); } catch (e) { /* in-memory only */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function forget() {
|
|
208
|
+
remembered = null;
|
|
209
|
+
try { localStorage.removeItem(storageKey); } catch (e) { /* in-memory only */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1s → 30s backoff, one attempt in flight, the same discipline the Studio's
|
|
213
|
+
// live transcript uses. Safe to repeat because minting has no side effect the
|
|
214
|
+
// customer can see: the worst case is an unused empty session.
|
|
215
|
+
function mint(attempt) {
|
|
216
|
+
return fetch(base + "/channels/" + channel + "/sessions", { method: "POST" })
|
|
217
|
+
.then(function (res) {
|
|
218
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
219
|
+
return res.json();
|
|
220
|
+
})
|
|
221
|
+
.then(function (body) {
|
|
222
|
+
if (!body.session_id) throw new Error("no session id");
|
|
223
|
+
return body.session_id;
|
|
224
|
+
})
|
|
225
|
+
.catch(function (err) {
|
|
226
|
+
if (attempt >= 4) throw new Error("cannot reach the assistant right now");
|
|
227
|
+
var wait = Math.min(30000, 1000 * Math.pow(2, attempt));
|
|
228
|
+
return delay(wait).then(function () { return mint(attempt + 1); });
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Minimal SSE reader over fetch — EventSource cannot POST, and the turn is a
|
|
233
|
+
// POST. Frames are `event: <name>\ndata: <json>\n\n`; anything else is skipped,
|
|
234
|
+
// so a frame added later cannot break an old widget.
|
|
235
|
+
function read(reader, onEvent) {
|
|
236
|
+
var decoder = new TextDecoder();
|
|
237
|
+
var buffer = "";
|
|
238
|
+
|
|
239
|
+
return reader.read().then(function step(chunk) {
|
|
240
|
+
if (chunk.done) return;
|
|
241
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
242
|
+
|
|
243
|
+
var parts = buffer.split("\n\n");
|
|
244
|
+
buffer = parts.pop();
|
|
245
|
+
parts.forEach(function (frame) {
|
|
246
|
+
var name = (frame.match(/^event: (.+)$/m) || [])[1];
|
|
247
|
+
var raw = (frame.match(/^data: (.*)$/m) || [])[1];
|
|
248
|
+
if (!name) return;
|
|
249
|
+
var data = {};
|
|
250
|
+
try { data = raw ? JSON.parse(raw) : {}; } catch (e) { return; }
|
|
251
|
+
onEvent(name, data);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return reader.read().then(step);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- helpers --------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function say(kind, content) {
|
|
261
|
+
var node = el("div", "insika-m " + kind);
|
|
262
|
+
node.textContent = content;
|
|
263
|
+
log.appendChild(node);
|
|
264
|
+
log.scrollTop = log.scrollHeight;
|
|
265
|
+
return node;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function el(tag, className) {
|
|
269
|
+
var node = document.createElement(tag);
|
|
270
|
+
node.className = className;
|
|
271
|
+
return node;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function text(tag, content) {
|
|
275
|
+
var node = document.createElement(tag);
|
|
276
|
+
node.textContent = content;
|
|
277
|
+
return node;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function delay(ms) {
|
|
281
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
module Channels
|
|
9
|
+
# The web widget (RFC-0011 §5) — the FIRST Shape A channel, and the adoption
|
|
10
|
+
# claim behind the whole RFC: an adopter pastes one tag into their site and has
|
|
11
|
+
# a working agent, with no backend of their own and no build step.
|
|
12
|
+
#
|
|
13
|
+
# <script src="https://agents.example.com/channels/web/asset/widget.js"
|
|
14
|
+
# data-agent="support" data-title="Ask us anything" defer></script>
|
|
15
|
+
#
|
|
16
|
+
# Shape A means the reply comes back on the request's own stream, so there is
|
|
17
|
+
# no outbox, no claim and no `deliver` here — the three routes and a static
|
|
18
|
+
# asset are the entire surface:
|
|
19
|
+
#
|
|
20
|
+
# POST /channels/web/sessions mint an opaque session id (the engine's, §4.3)
|
|
21
|
+
# POST /channels/web/messages the turn, answered as SSE on this connection
|
|
22
|
+
# GET /channels/web/asset/widget.js
|
|
23
|
+
#
|
|
24
|
+
# What makes this channel different from the relay is that it is PUBLIC: the
|
|
25
|
+
# caller is an anonymous browser, so there is no shared secret to check and the
|
|
26
|
+
# money faucet is real. Three things stand in for the missing credential, and
|
|
27
|
+
# all three are refusals, never grants (R2):
|
|
28
|
+
#
|
|
29
|
+
# · an AGENT allowlist — a visitor addresses the agents the operator published
|
|
30
|
+
# to the widget, not every agent in the deployment;
|
|
31
|
+
# · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
|
|
32
|
+
# · a mandatory chat RATE LIMIT (§5.4) — the channel answers `:disabled`
|
|
33
|
+
# until one is configured, because a public endpoint with an LLM behind it
|
|
34
|
+
# and no ceiling is an unmetered bill waiting to happen.
|
|
35
|
+
#
|
|
36
|
+
# R1/R2 hold: this object translates and refuses, and does nothing else. No
|
|
37
|
+
# Executor, no store, no RubyLLM — the rate-limit probe is injected as a plain
|
|
38
|
+
# callable so the channel never learns what a profile or a settings store is.
|
|
39
|
+
class Web
|
|
40
|
+
DEFAULT_ID = "web"
|
|
41
|
+
|
|
42
|
+
# The static files this channel serves, by request name. A closed map, not a
|
|
43
|
+
# directory listing: `asset/:f` takes a name from the URL, and anything that
|
|
44
|
+
# resolves a path from user input is a traversal waiting to be found.
|
|
45
|
+
ASSETS = {
|
|
46
|
+
"widget.js" => { file: "web/widget.js", content_type: "application/javascript; charset=utf-8" }
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# An unversioned URL cannot be cached for a year — the next release would
|
|
50
|
+
# never reach a browser that already has it. Short max-age + an ETag instead:
|
|
51
|
+
# the common case is a 304 with no body, and an upgrade lands within minutes.
|
|
52
|
+
# (A deliberate narrowing of §5.2's "long-cache versioned URL": the install
|
|
53
|
+
# snippet in the RFC has no version in it, so there is nothing to bust.)
|
|
54
|
+
ASSET_CACHE_CONTROL = "public, max-age=300"
|
|
55
|
+
|
|
56
|
+
attr_reader :id
|
|
57
|
+
|
|
58
|
+
# The bundled widget as an operator configures it. BOTH allowlists are the
|
|
59
|
+
# switch: a widget with no origins can be embedded nowhere, and one with no
|
|
60
|
+
# agents can address nothing, so either one missing means the operator has not
|
|
61
|
+
# actually asked for this channel. -> Web | nil.
|
|
62
|
+
def self.from_env(env = ENV, id: DEFAULT_ID, chat_rate_limit: nil)
|
|
63
|
+
origins = csv(Insika::EnvSchema.read("INSIKA_WIDGET_ORIGINS", env))
|
|
64
|
+
agents = csv(Insika::EnvSchema.read("INSIKA_WIDGET_AGENTS", env))
|
|
65
|
+
return nil if origins.empty? || agents.empty?
|
|
66
|
+
|
|
67
|
+
new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The probe §5.4's gate needs, asked exactly the way `EdgeLimiter` asks it at
|
|
71
|
+
# turn time: the per-agent override first, the platform default second. Built
|
|
72
|
+
# here so both composition roots wire the gate identically, and returned as a
|
|
73
|
+
# lambda so the channel itself stays store-free.
|
|
74
|
+
#
|
|
75
|
+
# Resolved on every check rather than at boot: an operator who removes the
|
|
76
|
+
# limit tomorrow closes the widget, instead of leaving it open because it was
|
|
77
|
+
# configured correctly once.
|
|
78
|
+
def self.limit_resolver(profiles:, settings_store: nil)
|
|
79
|
+
lambda do |agent_id|
|
|
80
|
+
limits = profiles&.fetch(agent_id.to_s)&.limits || {}
|
|
81
|
+
next limits[:chat_rate_limit] if limits.key?(:chat_rate_limit)
|
|
82
|
+
|
|
83
|
+
((settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.csv(value) = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
88
|
+
|
|
89
|
+
# origins: exact-match origin allowlist (`https://shop.example`). No
|
|
90
|
+
# wildcard, no "*" — CORS is a browser courtesy and a value
|
|
91
|
+
# that means "anyone" would only make it look like a control.
|
|
92
|
+
# agents: agent ids a visitor may address.
|
|
93
|
+
# chat_rate_limit: callable(agent_id) -> the effective limit, or nil. Absent ⇒
|
|
94
|
+
# the channel is `:disabled`, which is the fail-closed
|
|
95
|
+
# reading of "no ceiling configured".
|
|
96
|
+
def initialize(origins:, agents:, id: DEFAULT_ID, chat_rate_limit: nil)
|
|
97
|
+
@id = id.to_s
|
|
98
|
+
@origins = Array(origins).map(&:to_s)
|
|
99
|
+
@agents = Array(agents).map(&:to_s)
|
|
100
|
+
@chat_rate_limit = chat_rate_limit
|
|
101
|
+
@assets = {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# -> :ok | :unauthorized | :disabled. Same verdict vocabulary as every other
|
|
105
|
+
# channel (no Rack triple in `lib/`), and the same fail-closed default.
|
|
106
|
+
#
|
|
107
|
+
# A request with NO `Origin` is not a browser, so there is nothing for CORS to
|
|
108
|
+
# protect and refusing it would be theatre — curl can always set any origin it
|
|
109
|
+
# likes. The rate limit is what actually defends this route, which is why it
|
|
110
|
+
# is checked here and not left to the operator's memory.
|
|
111
|
+
def authenticate(req)
|
|
112
|
+
return :disabled if @agents.empty?
|
|
113
|
+
return :disabled unless rate_limited?
|
|
114
|
+
|
|
115
|
+
origin = req.get_header("HTTP_ORIGIN").to_s
|
|
116
|
+
return :ok if origin.empty?
|
|
117
|
+
|
|
118
|
+
allowed_origin?(origin) ? :ok : :unauthorized
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Is a chat rate limit configured for EVERY agent the widget publishes? All of
|
|
122
|
+
# them and not just the one being addressed: the check runs before the body is
|
|
123
|
+
# parsed, and "the widget is open for agent A but closed for agent B" is a
|
|
124
|
+
# posture nobody can hold in their head.
|
|
125
|
+
def rate_limited?
|
|
126
|
+
return false if @chat_rate_limit.nil?
|
|
127
|
+
|
|
128
|
+
!@agents.empty? && @agents.all? { |agent| @chat_rate_limit.call(agent).to_i.positive? }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# POST /channels/web/messages — STRING keys in, because this body comes off
|
|
132
|
+
# the public internet and nothing in it should become a symbol.
|
|
133
|
+
#
|
|
134
|
+
# { "agent": "support", "session_id": "web:8f3c…", "message": "oi" }
|
|
135
|
+
#
|
|
136
|
+
# The agent allowlist is checked HERE, which is a refusal and not a grant: the
|
|
137
|
+
# profile still decides what that agent may do.
|
|
138
|
+
def parse(_req, body:)
|
|
139
|
+
body = body.is_a?(Hash) ? body : {}
|
|
140
|
+
agent = body["agent"].to_s.strip
|
|
141
|
+
session_id = body["session_id"].to_s.strip
|
|
142
|
+
message = body["message"].to_s
|
|
143
|
+
|
|
144
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
145
|
+
raise Insika::ValidationError, "agent '#{agent}' is not published to the widget" unless @agents.include?(agent)
|
|
146
|
+
raise Insika::ValidationError, "session_id is required" if session_id.empty?
|
|
147
|
+
raise Insika::ValidationError, "message is required" if message.strip.empty?
|
|
148
|
+
|
|
149
|
+
{ agent: agent, session_id: session_id, message: message }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# §4.3's hard rule for a public channel: the ENGINE issues the id and the
|
|
153
|
+
# client never proposes one. A visitor-supplied session id on an anonymous
|
|
154
|
+
# endpoint is session hijacking by enumeration, so there is no create-on-write
|
|
155
|
+
# path — `POST /messages` with an unknown id is a 404, not a new conversation.
|
|
156
|
+
def mint_session_id = "#{@id}:#{SecureRandom.hex(16)}"
|
|
157
|
+
|
|
158
|
+
# Turn Event -> SSE frame | nil. Four frames, which is the whole widget
|
|
159
|
+
# protocol: what to type, what to say while a tool runs, and how it ended.
|
|
160
|
+
#
|
|
161
|
+
# `:intermediate` and `:thinking` are deliberately absent. `:content` is the
|
|
162
|
+
# ANSWER (P19) — the model's narration on the way there is internal, and a
|
|
163
|
+
# widget that rendered it would show the customer the engine thinking out loud.
|
|
164
|
+
def frame_for(event)
|
|
165
|
+
case event.type
|
|
166
|
+
when :content then sse("delta", { delta: event.data[:delta].to_s })
|
|
167
|
+
when :tool_call then sse("working", { name: event.data[:name].to_s })
|
|
168
|
+
when :task_completed then sse("done", {})
|
|
169
|
+
when :task_failed then sse("error", { message: event.data[:message].to_s })
|
|
170
|
+
when :task_cancelled then sse("error", { message: "task cancelled" })
|
|
171
|
+
when :error then sse("error", { message: event.data[:message].to_s })
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# GET /channels/web/asset/:f -> { content_type:, body:, etag: } | nil.
|
|
176
|
+
# Read once and memoized: the file ships with the gem and cannot change under
|
|
177
|
+
# a running process.
|
|
178
|
+
def asset(name)
|
|
179
|
+
entry = ASSETS[name.to_s]
|
|
180
|
+
return nil if entry.nil?
|
|
181
|
+
|
|
182
|
+
@assets[name.to_s] ||= begin
|
|
183
|
+
body = File.read(File.expand_path(entry[:file], __dir__))
|
|
184
|
+
{ content_type: entry[:content_type], body: body,
|
|
185
|
+
etag: %("#{Digest::SHA256.hexdigest(body)[0, 16]}"),
|
|
186
|
+
cache_control: ASSET_CACHE_CONTROL }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# -> the CORS headers for this request's origin. An origin that is not on the
|
|
191
|
+
# list simply gets none, and the browser refuses the response itself — the
|
|
192
|
+
# engine does not have to pretend that is an authorization decision.
|
|
193
|
+
def cors_headers(origin)
|
|
194
|
+
o = origin.to_s
|
|
195
|
+
return { "vary" => "origin" } unless allowed_origin?(o)
|
|
196
|
+
|
|
197
|
+
{ "access-control-allow-origin" => o,
|
|
198
|
+
"vary" => "origin",
|
|
199
|
+
"access-control-allow-headers" => "content-type",
|
|
200
|
+
"access-control-allow-methods" => "POST, OPTIONS",
|
|
201
|
+
"access-control-max-age" => "600" }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def allowed_origin?(origin) = !origin.empty? && @origins.include?(origin)
|
|
207
|
+
|
|
208
|
+
def sse(name, data) = "event: #{name}\ndata: #{JSON.generate(data)}\n\n"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|