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.
Files changed (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +362 -0
  22. data/docs/SKILLS.md +98 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. 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