insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -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 +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -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 +375 -0
  22. data/docs/SKILLS.md +284 -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 +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,283 @@
1
+ // Insika web widget.
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
+ // (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 — 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)
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 — 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'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'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
+ #'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 — 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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Channels
7
+ # A webhook as a Shape B "channel": the recipient of operator ALERTS
8
+ # (WS6). One instance per configured URL, registered in the ChannelRegistry
9
+ # so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
10
+ # same way it delivers a chat answer — at-most-once, bounded retry, boot
11
+ # sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
12
+ # the consumer interprets it (the engine transports, it does not integrate).
13
+ #
14
+ # The target URL is operator configuration, so the POST crosses the SAME
15
+ # egress guard the Relay applies: https-only (fails closed), private/
16
+ # loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
17
+ # `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
18
+ # or an internal API exfiltrates alert events out of the boundary (WS6).
19
+ class Webhook
20
+ def initialize(url, http:, allow_http: false, allow_private: false)
21
+ @http = http
22
+ @allow_http = allow_http
23
+ @allow_private = allow_private
24
+ end
25
+
26
+ # The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
27
+ # Every failure becomes a DeliveryError so the bounded retry records it.
28
+ def deliver(payload, to:, delivery_id: nil)
29
+ if (reason = egress_violation(to))
30
+ raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
31
+ end
32
+
33
+ result = @http.request(
34
+ method: :post, url: to,
35
+ headers: { "content-type" => "application/json" },
36
+ body: JSON.generate(payload)
37
+ )
38
+ result[:status].to_i
39
+ rescue Insika::DeliveryError
40
+ raise
41
+ rescue StandardError => e
42
+ raise Insika::DeliveryError, "webhook: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ # Resolved on EVERY delivery, not once at registration: a host that
48
+ # answered a public address yesterday can answer 169.254.169.254 today,
49
+ # and this POST carries the operator's alerts.
50
+ def egress_violation(url)
51
+ Insika::EgressGuard.violation(url, allow_http: @allow_http,
52
+ allow_private: @allow_private)
53
+ rescue URI::InvalidURIError
54
+ "invalid URL"
55
+ end
56
+ end
57
+ end
58
+ end