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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+
5
+ module Insika
6
+ module Server
7
+ # Turns the components into a service.
8
+ # MANDATORY order, no parallelism: plugins → stores → recovery → (app
9
+ # for the listen). "Never accepts a request before recovery" is guaranteed BY
10
+ # CONSTRUCTION: the listen (Falcon) only runs after `#call` returns the app, and
11
+ # `#call` only returns after `Recovery.run` finishes.
12
+ class Boot
13
+ # wiring: object with the named steps (load_plugins/build_stores/
14
+ # recovery/app) — the config/wiring.rb. logger: simple IO (default $stdout;
15
+ # nil silences). app: overrides the wiring's `app` step — for a serving arm
16
+ # (config.ru) that assembles its own Rack app around the wiring; the
17
+ # "recovery before the listen" guarantee is unchanged (#call still only
18
+ # returns after recovery).
19
+ def initialize(wiring, logger: $stdout, app: nil)
20
+ @wiring = wiring
21
+ @logger = logger
22
+ @app = app
23
+ end
24
+
25
+ # -> Rack app ready for the `run`. A store failure at boot (corrupted
26
+ # file → StoreError) PROPAGATES and aborts the process (coming up
27
+ # without durability is worse than not coming up); an unrecoverable task does NOT
28
+ # bring down the boot (Recovery already marks it :failed).
29
+ def call
30
+ @wiring.load_plugins
31
+ @wiring.build_stores
32
+ warn_if_ephemeral
33
+ summary = run_recovery
34
+ log("boot: recovery complete — #{summary[:resumed].size} resumed, " \
35
+ "#{summary[:failed].size} failed")
36
+ @app || @wiring.app
37
+ end
38
+
39
+ private
40
+
41
+ # Recovery dispatches resume_task, which creates task fibers — needs a
42
+ # current reactor. At config.ru load (Falcon) there is NO reactor: the Sync { }
43
+ # creates one and, by structured concurrency, only returns when the resume
44
+ # fibers FINISH (recovery + turns completed before the listen — slower
45
+ # boot, semantically safe). Under an already-current reactor (tests
46
+ # inside Async), it runs directly: returns after the resume DISPATCH, with
47
+ # the turns still in flight — also correct: "recovery before the listen" =
48
+ # dispatch before the listen, not turn completion.
49
+ def run_recovery
50
+ return do_recovery if Async::Task.current?
51
+
52
+ Sync { do_recovery }
53
+ end
54
+
55
+ # Task recovery THEN delegation recovery: the delegation
56
+ # sweep re-delivers completed-but-undelivered async delegations, and depends
57
+ # on the task sweep having re-dispatched any in-flight children first. Both
58
+ # create task fibers, so both must run inside the reactor scope of run_recovery.
59
+ #
60
+ # The TASK sweep is additionally gated per boot generation:
61
+ # its "orphaned :running" test cannot see a sibling worker's live fiber, so
62
+ # only the worker that claims the generation sweeps — the others would steal
63
+ # in-flight turns. The delegation and channel sweeps stay ungated: each of
64
+ # their records carries its own transactional claim (at-most-once holds
65
+ # however many workers sweep). Duck-typed: a wiring without the claim (test
66
+ # doubles, single-process arms) sweeps unconditionally.
67
+ def do_recovery
68
+ summary =
69
+ if skip_task_sweep?
70
+ log("boot: task sweep skipped — another worker claimed this boot generation")
71
+ { resumed: [], failed: [] }
72
+ else
73
+ @wiring.recovery.run
74
+ end
75
+ recover_delegations
76
+ recover_channel_deliveries
77
+ summary
78
+ end
79
+
80
+ def skip_task_sweep?
81
+ @wiring.respond_to?(:claim_recovery_sweep) && !@wiring.claim_recovery_sweep
82
+ end
83
+
84
+ # Duck-typed (like durable?): a wiring without async delegation just omits it.
85
+ def recover_delegations
86
+ return unless @wiring.respond_to?(:recover_delegations)
87
+
88
+ result = @wiring.recover_delegations
89
+ log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
90
+ end
91
+
92
+ # replies a previous process committed but never handed to the
93
+ # channel. Runs AFTER the task recovery for the same reason the delegation
94
+ # sweep does — a resumed turn writes its own outbox record at its terminal, and
95
+ # sweeping first would miss it.
96
+ def recover_channel_deliveries
97
+ return unless @wiring.respond_to?(:recover_channel_deliveries)
98
+
99
+ result = @wiring.recover_channel_deliveries
100
+ log("boot: channel replies re-dispatched — #{Array(result && result[:dispatched]).size}")
101
+ end
102
+
103
+ # Durability: without a durable backend, nothing is resumed after a
104
+ # restart — warns loudly at boot so we don't come up "without a net" by mistake. The
105
+ # test wiring (double) may not expose `durable?`; in that case, silence.
106
+ def warn_if_ephemeral
107
+ return unless @wiring.respond_to?(:durable?)
108
+ return if @wiring.durable?
109
+
110
+ log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
111
+ "not resume anything after a restart.")
112
+ end
113
+
114
+ def log(message)
115
+ @logger&.puts(message)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # the /v1 transport as a VALUE the host app mounts, instead of a
4
+ # server the engine starts.
5
+ #
6
+ # mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
7
+ #
8
+ # The assembly used to live inline in DSL::ServerBoot#run, welded to
9
+ # `Async::HTTP::Server.new(...).run` on the next line: a host that already owns a
10
+ # reactor and a router could not reach it. Nothing here is new behavior — the
11
+ # server boot now calls this instead of inlining it, which is what keeps the two
12
+ # from drifting.
13
+ #
14
+ # The Studio is deliberately NOT part of this (the embed contract): it is
15
+ # a class-level singleton, so it is one per process, and a host that wants the
16
+ # operator UI mounts `Studio::App` itself and accepts that limitation.
17
+
18
+ require_relative "app"
19
+
20
+ module Insika
21
+ module Server
22
+ # `handle` is anything that answers #runtime (a DSL Definition/System) or a
23
+ # DSL::Runtime itself. -> a Rack app (`#call(env)`).
24
+ #
25
+ # It is mount-safe: routing reads `path_info`, so `Rack::URLMap`/Rails' `mount`
26
+ # moving the prefix into SCRIPT_NAME leaves every route intact.
27
+ def self.rack_app(handle, token: nil, **config)
28
+ AppBuilder.new(handle, token: token, **config).app
29
+ end
30
+
31
+ # Assembles Server::App from a graph. Also answers the two questions the boot
32
+ # banner asks (`workflows?`/`channels?`), so registering the env channels
33
+ # happens exactly once and in one place.
34
+ class AppBuilder
35
+ # Fixed local token: gates /v1 (Bearer) — and, under `serve`, logs into the
36
+ # Studio (cookie). Never a real secret; override with `token:`/ADMIN_TOKEN.
37
+ def initialize(handle, token: nil, **config)
38
+ @rt = handle.respond_to?(:runtime) ? handle.runtime : handle
39
+ @graph = @rt.graph
40
+ @token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
41
+ @config = config
42
+ end
43
+
44
+ attr_reader :token
45
+
46
+ def app
47
+ tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
48
+ # WS1: the token store is handed over ONLY in multi_tenant mode — in
49
+ # single_tenant the classic gateway token is the only credential
50
+ # (passing the store would silently widen the surface).
51
+ store = tenancy == "multi_tenant" ? @graph.token_store : nil
52
+ @app ||= Insika::Server::App.new(
53
+ command_bus: @graph.bus, event_stream: @graph.event_stream,
54
+ session_store: @graph.session_store, task_store: @graph.task_store,
55
+ pending_action_store: @graph.pending_action_store,
56
+ provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
57
+ # GET /v1/agents/:id — the read-only capability view a case's `requires`
58
+ # resolves against.
59
+ profiles: @graph.profiles,
60
+ # the OSS onboarding surface (start.md + models.json + docs).
61
+ # This is the primary "build my first agent" target — models.json reports the
62
+ # DSL's stores + the agents this process serves (each id IS the `model`).
63
+ onboarding: build_onboarding,
64
+ # GET /v1/workflows + POST /v1/workflows/:name, opt-in by
65
+ # injection like every other edge — nil when the system declares none,
66
+ # so the routes simply do not exist (404, parity).
67
+ workflow_registry: (@graph.workflow_registry if workflows?),
68
+ # the bundled relay, when the env turns it on. Same rule as the
69
+ # OTEL bridge — a feature only `config.ru` can reach is a feature the
70
+ # docs are half-true about.
71
+ channels: (@graph.channel_registry if channels?),
72
+ config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
73
+ token_store: store,
74
+ # a 500's error_ref must be findable in the process log.
75
+ logger: $stdout
76
+ )
77
+ end
78
+
79
+ def workflows? = !@graph.workflow_registry.names.empty?
80
+
81
+ # Registers the env-configured channels once, and reports whether any exist.
82
+ def channels?
83
+ unless defined?(@channels_ready)
84
+ @channels_ready = true
85
+ relay = Insika::Channels::Relay.from_env(
86
+ http: Insika::HttpClient.new,
87
+ allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
88
+ allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
89
+ )
90
+ @graph.channel_registry.register(relay.id, relay) if relay
91
+
92
+ widget = Insika::Channels::Web.from_env(
93
+ chat_rate_limit: Insika::Channels::Web.limit_resolver(
94
+ profiles: @graph.profiles, settings_store: @rt.component(:settings_store)
95
+ )
96
+ )
97
+ @graph.channel_registry.register(widget.id, widget) if widget
98
+ end
99
+ !@graph.channel_registry.names.empty?
100
+ end
101
+
102
+ private
103
+
104
+ def build_onboarding
105
+ configs = @rt.packs.map(&:config)
106
+ Insika::Onboarding.standard(
107
+ root: File.expand_path("../../..", __dir__),
108
+ settings_store: @rt.component(:settings_store),
109
+ provider_store: @rt.component(:provider_store),
110
+ # EVERY agent this process serves — each id IS a `model` on
111
+ # /v1/responses, so a coding agent reading models.json sees the whole
112
+ # system, not just the first one.
113
+ agents: -> { configs.map { |c| { id: c[:id], model: c[:model], provider: c[:provider] } } }
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Server
7
+ # OpenAI Responses edge adapter (`/v1/responses`) — the contract that
8
+ # OpenClaw gateway consumers already speak.
9
+ #
10
+ # PURE module (no state, no framework): (a) translates the OpenAI
11
+ # Responses request → `:send_message` payload; (b) maps each turn Event →
12
+ # OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
13
+ # constitutional rule: no business logic, no store access here.
14
+ #
15
+ # Request: { model: "openclaw:<agent>", user: "<chat.id>", stream: true,
16
+ # input: "<string with already-composed blocks>" } + header
17
+ # X-Openclaw-Agent (agent fallback). The `input` enters VERBATIM as the
18
+ # turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
19
+ # composed by the consumer (the engine does not interpret them).
20
+ module Responses
21
+ module_function
22
+
23
+ # -> { agent:, user:, message:, origin? } | raise ValidationError.
24
+ #
25
+ # `origin` is the consumer declaring WHO wrote the input it is sending. It
26
+ # matters here more than anywhere: this adapter's `input` is a STRING the
27
+ # consumer already composed out of context blocks plus the customer's text
28
+ # (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
29
+ # tell the two apart — the first refinement run over real traffic reported 219
30
+ # "the customer repeated themselves" that were the engine reading its own
31
+ # fragment back. A consumer that sends `origin: "engine"` on a composed turn
32
+ # gets that filtered structurally instead of by a regex on the leading tag.
33
+ # Omitted = a customer typed it, which is what every turn meant before.
34
+ def parse_request(body, req)
35
+ agent = body[:model].to_s.sub(/\Aopenclaw:/, "")
36
+ agent = req.get_header("HTTP_X_OPENCLAW_AGENT").to_s if agent.empty?
37
+ raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
38
+
39
+ user = body[:user].to_s
40
+ raise Insika::ValidationError, "user missing" if user.strip.empty?
41
+
42
+ message = extract_input(body[:input])
43
+ raise Insika::ValidationError, "input empty" if message.strip.empty?
44
+
45
+ out = { agent: agent.strip, user: user, message: message }
46
+ (origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
47
+ out
48
+ end
49
+
50
+ # V1: `input` is a STRING (the dispatcher composes the blocks + user text). Tolerates
51
+ # an array of parts (OpenAI multimodal shape) by joining the texts.
52
+ def extract_input(input)
53
+ case input
54
+ when String then input
55
+ when Array
56
+ input.flat_map { |part| part.is_a?(Hash) ? (part[:text] || part["text"]) : part }
57
+ .compact.join("\n")
58
+ else input.to_s
59
+ end
60
+ end
61
+
62
+ # Turn Event -> OpenAI Responses SSE frame | nil (event with no
63
+ # counterpart: :task_started, :tool_result, :skill_activated, ...).
64
+ # Terminal events emit the final frame + `[DONE]` (close the stream).
65
+ def frame_for(event)
66
+ case event.type
67
+ when :content
68
+ sse("response.output_text.delta",
69
+ { type: "response.output_text.delta", delta: event.data[:delta].to_s })
70
+ when :tool_call
71
+ sse("response.output_item.added",
72
+ { type: "response.output_item.added",
73
+ item: { type: "function_call", name: event.data[:name].to_s } })
74
+ when :task_completed
75
+ completed(event) + done
76
+ when :task_failed
77
+ failed(event.data[:message] || "task failed") + done
78
+ when :task_cancelled
79
+ failed("task cancelled") + done
80
+ when :error
81
+ failed(event.data[:message] || "error") + done
82
+ when :thinking
83
+ # The provider's reasoning. Internal unless the AGENT opted in
84
+ # (`edge_stream thinking: true`), which tags the event. Even then it does
85
+ # NOT become answer text: it gets the Responses reasoning frame, so a
86
+ # consumer that only accumulates `output_text` deltas — a dispatcher
87
+ # that turns them into one WhatsApp message — is unaffected,
88
+ # and one that renders reasoning has something to render.
89
+ if public_delta(event)
90
+ sse("response.reasoning_summary_text.delta",
91
+ { type: "response.reasoning_summary_text.delta", delta: event.data[:delta].to_s })
92
+ end
93
+ when :intermediate
94
+ # The model's own prose that did not turn out to be the answer — the
95
+ # narration of a message that also called a tool, or the reasoning-in-content
96
+ # a model emits when it has no tool to call. A real store's prompt sent 132
97
+ # deltas of an English monologue this way before TurnOutput held them back.
98
+ #
99
+ # NAMESPACED on purpose when published. There is no `response.*` event for
100
+ # "text the assistant said that is not the answer": in the real protocol that
101
+ # text IS `output_text.delta`, told apart only by an output-item index this
102
+ # adapter does not carry. So a `response.*` type here would be a lie a strict
103
+ # client would believe. `insika.*` is obviously ours and unknown types are
104
+ # ignored — which is the safe failure.
105
+ if public_delta(event)
106
+ sse("insika.intermediate.delta",
107
+ { type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
108
+ end
109
+ when :guardrail_blocked, :guardrail_flagged
110
+ # audit events with no OpenAI Responses counterpart. On a BLOCK
111
+ # the safe reply still reaches the consumer through the normal :content
112
+ # deltas + :task_completed path (the turn completes gracefully), so there
113
+ # is nothing extra to translate here — the events live in /v1/events + the
114
+ # Studio + the trace. Explicit (not a fall-through) to keep the closed
115
+ # catalog honest.
116
+ nil
117
+ when :ttft
118
+ # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
119
+ # ms-to-first-token, emitted when the first content chunk arrives.
120
+ # Namespaced insika.* — no OpenAI Responses counterpart; unknown types
121
+ # are ignored, the safe failure.
122
+ sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
123
+ end
124
+ end
125
+
126
+ # Did the AGENT opt this channel in? The Executor tags the event (`edge_stream`)
127
+ # because this mapper is pure and static — no agent, no stores, no state. An
128
+ # untagged event is internal, which is the default and the safe reading — and
129
+ # "not published" must be nil, like every other unmapped event in the catalog.
130
+ def public_delta(event) = event.data[:public] == true
131
+
132
+ def completed(event)
133
+ response = {}
134
+ if (usage = event.data[:usage])
135
+ # `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
136
+ # usage (pure tokens in usage).
137
+ model = usage[:model] || usage["model"]
138
+ response[:usage] = usage.reject { |k, _| k.to_s == "model" }
139
+ response[:model] = model if model
140
+ end
141
+ # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
142
+ # by default — a non-standard sibling used only for TTFB diagnostics.
143
+ (timing = event.data[:timing]) && (response[:timing] = timing)
144
+ # WS5 stuck signal: an additive sibling the terminal frame carries when the
145
+ # agent ended the turn declaring it cannot proceed. Consumers that only read
146
+ # the OpenAI-shaped response.use it to run their escalation ("stuck" means
147
+ # what they decide it means, never the engine's business).
148
+ (outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
149
+ sse("response.completed", { type: "response.completed", response: response })
150
+ end
151
+
152
+ def failed(message)
153
+ sse("response.failed",
154
+ { type: "response.failed", response: { error: { message: message.to_s } } })
155
+ end
156
+
157
+ # event: + data: (the dispatcher reads both: `event:` and `type` in the JSON).
158
+ def sse(event_name, data)
159
+ "event: #{event_name}\ndata: #{JSON.generate(data)}\n\n"
160
+ end
161
+
162
+ def done = "data: [DONE]\n\n"
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "async"
6
+ require "async/queue"
7
+
8
+ module Insika
9
+ module Server
10
+ # SSE response body (evolves `SSEStream`). `SSEStream`
11
+ # received a producer block (the Runner wrote into it); `SSEBody` DRAINS a
12
+ # `Subscription` from the EventStream: each subscriber has its
13
+ # own queue; `#each` blocks the CONSUMER's fiber until the subscription
14
+ # closes. The wire is EXACTLY `Event#to_h`.
15
+ class SSEBody
16
+ PING = ": ping\n\n" # SSE comment — doesn't pollute the consumer
17
+
18
+ # serialize: maps an Event -> String (SSE frame) OR nil (discarded
19
+ # event, no frame). Default = the canonical wire `data: <Event#to_h>`.
20
+ # The /v1/responses adapter injects a serializer that produces OpenAI
21
+ # Responses events (and skips those with no counterpart).
22
+ DEFAULT_SERIALIZE = ->(event) { "data: #{JSON.generate(event.to_h)}\n\n" }
23
+
24
+ # subscription: any object with #each (yields Events) and #close.
25
+ # heartbeat: seconds of silence before emitting a ping (15s
26
+ # clears 60s ALB/nginx idle timeouts with room to spare).
27
+ def initialize(subscription:, heartbeat: 15, serialize: nil)
28
+ @subscription = subscription
29
+ @heartbeat = heartbeat
30
+ @serialize = serialize || DEFAULT_SERIALIZE
31
+ end
32
+
33
+ # Rack 3 STREAMING BODY (`#call(stream)`), NOT `#each`. Under
34
+ # protocol-rack/protocol-http1 (the stack of Async::HTTP::Server AND Falcon),
35
+ # a body that responds to `#each` is routed to Body::Enumerable, whose
36
+ # `read` runs the `#each` in a PLAIN Enumerator Fiber (not an Async::Task) —
37
+ # there `Async::Task.current` raises "No async task available", the loop died
38
+ # swallowed in the rescue and the body came out EMPTY. By exposing `#call` (and NOT `#each`),
39
+ # the body is routed to Body::Streaming, which schedules the block via
40
+ # Fiber.schedule under the reactor's scheduler — so the subscription drains and the
41
+ # frames actually reach the socket (incrementally).
42
+ #
43
+ # The `stream` (Protocol::HTTP::Body::Stream) responds to #write/#close. Drains the
44
+ # subscription DIRECTLY (no Async::Task.current): when this fiber blocks
45
+ # waiting for the next event, the scheduler runs the writer, which pushes the
46
+ # already-written frame to the socket. Heartbeat via Timeout.timeout (scheduler hook),
47
+ # which works in the scheduled fiber — keeps the connection alive while idle (L4).
48
+ def call(stream)
49
+ drain(stream)
50
+ rescue StandardError
51
+ # Client disconnected: `stream.write` raises when the socket closes.
52
+ # No exception escapes; the turn's task is NEVER cancelled here — the
53
+ # execution belongs to the runtime, not the connection (reconnect at /v1/events).
54
+ nil
55
+ ensure
56
+ @subscription.close
57
+ stream.close
58
+ end
59
+
60
+ private
61
+
62
+ def drain(stream)
63
+ internal = Async::Queue.new
64
+ closed = Object.new # end-of-subscription sentinel
65
+
66
+ # Child fiber (scheduled on the reactor's scheduler): drains the subscription
67
+ # into an internal queue and, on close, pushes the sentinel. Isolates the
68
+ # subscription's blocking from the heartbeat loop. Ends on its own when
69
+ # `@subscription.close` (in #call's ensure) makes the `each` finish — no
70
+ # need to kill the fiber by hand.
71
+ Fiber.schedule do
72
+ @subscription.each { |event| internal.enqueue(event) }
73
+ ensure
74
+ internal.enqueue(closed)
75
+ end
76
+
77
+ loop do
78
+ event =
79
+ begin
80
+ Timeout.timeout(@heartbeat) { internal.dequeue }
81
+ rescue Timeout::Error
82
+ :heartbeat # no event within `heartbeat`s -> ping
83
+ end
84
+
85
+ if event.equal?(:heartbeat)
86
+ stream.write(PING)
87
+ elsif event.equal?(closed)
88
+ break
89
+ elsif (frame = @serialize.call(event))
90
+ stream.write(frame)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/utils"
5
+
6
+ module Insika
7
+ module Server
8
+ # Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
9
+ # a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
10
+ # one gate:
11
+ #
12
+ # single_tenant (default) — no token store: the classic single operator
13
+ # credential (config[:gateway_token]) is the only thing that resolves.
14
+ # multi_tenant — tokens live in the TokenStore (per-tenant + operator).
15
+ # A configured gateway_token STILL resolves as operator (an existing
16
+ # deployment switching modes keeps its credential — additive, never
17
+ # a second-class path).
18
+ #
19
+ # Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
20
+ # (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
21
+ # testable without a Rack env.
22
+ module TenantAuth
23
+ module_function
24
+
25
+ # gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
26
+ # nil. header: raw Authorization value.
27
+ # -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
28
+ def check(gateway_token, token_store, header)
29
+ # Fail-closed FIRST (the construction rule): with no store AND no
30
+ # configured token the gateway is DISABLED (503) however the request
31
+ # looks — never "401: who are you facing a door that does not exist".
32
+ # A token_store present means the gateway IS configured (multi_tenant),
33
+ # with or without the legacy gateway token.
34
+ return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
35
+
36
+ provided = header.to_s[/\ABearer (.+)\z/, 1]
37
+ return :unauthorized if provided.nil?
38
+
39
+ if token_store
40
+ record = token_store.resolve(provided)
41
+ unless record
42
+ # store miss -> the legacy gateway token still resolves as operator
43
+ # (an existing deployment switching modes keeps its credential).
44
+ return :unauthorized if gateway_token.nil? || gateway_token.empty?
45
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
46
+
47
+ return { role: "operator", tenant_id: nil }
48
+ end
49
+
50
+ return { role: record.role.to_s, tenant_id: record.tenant_id }
51
+ end
52
+
53
+ # classic mode (no store): the gateway token is the only credential.
54
+ # Constant-time comparison: the operator token doesn't leak via timing.
55
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
56
+
57
+ { role: "operator", tenant_id: nil }
58
+ end
59
+ end
60
+ end
61
+ end