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,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 (RFC-0010 Fase 2): 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 (RFC-0016 E2):
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
+ # RFC-0011 §6.5: 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 (doc 02 §6).")
112
+ end
113
+
114
+ def log(message)
115
+ @logger&.puts(message)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RFC-0017 A3 — 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, item 4): 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
+ @app ||= Insika::Server::App.new(
48
+ command_bus: @graph.bus, event_stream: @graph.event_stream,
49
+ session_store: @graph.session_store, task_store: @graph.task_store,
50
+ pending_action_store: @graph.pending_action_store,
51
+ provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
52
+ # GET /v1/agents/:id — the read-only capability view a case's `requires`
53
+ # resolves against (RFC-0014 §3.2).
54
+ profiles: @graph.profiles,
55
+ # Item 20 / §5.6: the OSS onboarding surface (start.md + models.json + docs).
56
+ # This is the primary "build my first agent" target — models.json reports the
57
+ # DSL's stores + the agents this process serves (each id IS the `model`).
58
+ onboarding: build_onboarding,
59
+ # Item 22: GET /v1/workflows + POST /v1/workflows/:name, opt-in by
60
+ # injection like every other edge — nil when the system declares none,
61
+ # so the routes simply do not exist (404, parity).
62
+ workflow_registry: (@graph.workflow_registry if workflows?),
63
+ # RFC-0011: the bundled relay, when the env turns it on. Same rule as the
64
+ # OTEL bridge — a feature only `config.ru` can reach is a feature the
65
+ # docs are half-true about.
66
+ channels: (@graph.channel_registry if channels?),
67
+ config: { gateway_token: @token }.merge(@config)
68
+ )
69
+ end
70
+
71
+ def workflows? = !@graph.workflow_registry.names.empty?
72
+
73
+ # Registers the env-configured channels once, and reports whether any exist.
74
+ def channels?
75
+ unless defined?(@channels_ready)
76
+ @channels_ready = true
77
+ relay = Insika::Channels::Relay.from_env(
78
+ http: Insika::HttpClient.new,
79
+ allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
80
+ allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
81
+ )
82
+ @graph.channel_registry.register(relay.id, relay) if relay
83
+
84
+ widget = Insika::Channels::Web.from_env(
85
+ chat_rate_limit: Insika::Channels::Web.limit_resolver(
86
+ profiles: @graph.profiles, settings_store: @rt.component(:settings_store)
87
+ )
88
+ )
89
+ @graph.channel_registry.register(widget.id, widget) if widget
90
+ end
91
+ !@graph.channel_registry.names.empty?
92
+ end
93
+
94
+ private
95
+
96
+ def build_onboarding
97
+ configs = @rt.packs.map(&:config)
98
+ Insika::Onboarding.standard(
99
+ root: File.expand_path("../../..", __dir__),
100
+ settings_store: @rt.component(:settings_store),
101
+ provider_store: @rt.component(:provider_store),
102
+ # EVERY agent this process serves — each id IS a `model` on
103
+ # /v1/responses, so a coding agent reading models.json sees the whole
104
+ # system, not just the first one.
105
+ agents: -> { configs.map { |c| { id: c[:id], model: c[:model], provider: c[:provider] } } }
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,155 @@
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 the
8
+ # OpenClaw gateway consumers already speak (see achei-b2b
9
+ # `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
10
+ #
11
+ # PURE module (no state, no framework): (a) translates the OpenAI
12
+ # Responses request → `:send_message` payload; (b) maps each turn Event →
13
+ # OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
14
+ # constitutional rule: no business logic, no store access here.
15
+ #
16
+ # Request: { model: "openclaw:<agent>", user: "<chat.id>", stream: true,
17
+ # input: "<string with already-composed blocks>" } + header
18
+ # X-Openclaw-Agent (agent fallback). The `input` enters VERBATIM as the
19
+ # turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
20
+ # composed by the consumer (the engine does not interpret them).
21
+ module Responses
22
+ module_function
23
+
24
+ # -> { agent:, user:, message:, origin? } | raise ValidationError.
25
+ #
26
+ # `origin` is the consumer declaring WHO wrote the input it is sending. It
27
+ # matters here more than anywhere: this adapter's `input` is a STRING the
28
+ # consumer already composed out of context blocks plus the customer's text
29
+ # (`<memoria> …`, `<cacau_cep_obrigatorio> …`), so a transcript reader cannot
30
+ # tell the two apart — the first refinement run over real traffic reported 219
31
+ # "the customer repeated themselves" that were the engine reading its own
32
+ # fragment back. A consumer that sends `origin: "engine"` on a composed turn
33
+ # gets that filtered structurally instead of by a regex on the leading tag.
34
+ # Omitted = a customer typed it, which is what every turn meant before.
35
+ def parse_request(body, req)
36
+ agent = body[:model].to_s.sub(/\Aopenclaw:/, "")
37
+ agent = req.get_header("HTTP_X_OPENCLAW_AGENT").to_s if agent.empty?
38
+ raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
39
+
40
+ user = body[:user].to_s
41
+ raise Insika::ValidationError, "user missing" if user.strip.empty?
42
+
43
+ message = extract_input(body[:input])
44
+ raise Insika::ValidationError, "input empty" if message.strip.empty?
45
+
46
+ out = { agent: agent.strip, user: user, message: message }
47
+ (origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
48
+ out
49
+ end
50
+
51
+ # V1: `input` is a STRING (the dispatcher composes the blocks + user text). Tolerates
52
+ # an array of parts (OpenAI multimodal shape) by joining the texts.
53
+ def extract_input(input)
54
+ case input
55
+ when String then input
56
+ when Array
57
+ input.flat_map { |part| part.is_a?(Hash) ? (part[:text] || part["text"]) : part }
58
+ .compact.join("\n")
59
+ else input.to_s
60
+ end
61
+ end
62
+
63
+ # Turn Event -> OpenAI Responses SSE frame | nil (event with no
64
+ # counterpart: :task_started, :tool_result, :skill_activated, ...).
65
+ # Terminal events emit the final frame + `[DONE]` (close the stream).
66
+ def frame_for(event)
67
+ case event.type
68
+ when :content
69
+ sse("response.output_text.delta",
70
+ { type: "response.output_text.delta", delta: event.data[:delta].to_s })
71
+ when :tool_call
72
+ sse("response.output_item.added",
73
+ { type: "response.output_item.added",
74
+ item: { type: "function_call", name: event.data[:name].to_s } })
75
+ when :task_completed
76
+ completed(event) + done
77
+ when :task_failed
78
+ failed(event.data[:message] || "task failed") + done
79
+ when :task_cancelled
80
+ failed("task cancelled") + done
81
+ when :error
82
+ failed(event.data[:message] || "error") + done
83
+ when :thinking
84
+ # The provider's reasoning. Internal unless the AGENT opted in
85
+ # (`edge_stream thinking: true`), which tags the event. Even then it does
86
+ # NOT become answer text: it gets the Responses reasoning frame, so a
87
+ # consumer that only accumulates `output_text` deltas — achei-b2b's
88
+ # dispatcher, which turns them into one WhatsApp message — is unaffected,
89
+ # and one that renders reasoning has something to render.
90
+ if public_delta(event)
91
+ sse("response.reasoning_summary_text.delta",
92
+ { type: "response.reasoning_summary_text.delta", delta: event.data[:delta].to_s })
93
+ end
94
+ when :intermediate
95
+ # The model's own prose that did not turn out to be the answer — the
96
+ # narration of a message that also called a tool, or the reasoning-in-content
97
+ # a model emits when it has no tool to call. A real store's prompt sent 132
98
+ # deltas of an English monologue this way before TurnOutput held them back.
99
+ #
100
+ # NAMESPACED on purpose when published. There is no `response.*` event for
101
+ # "text the assistant said that is not the answer": in the real protocol that
102
+ # text IS `output_text.delta`, told apart only by an output-item index this
103
+ # adapter does not carry. So a `response.*` type here would be a lie a strict
104
+ # client would believe. `insika.*` is obviously ours and unknown types are
105
+ # ignored — which is the safe failure.
106
+ if public_delta(event)
107
+ sse("insika.intermediate.delta",
108
+ { type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
109
+ end
110
+ when :guardrail_blocked, :guardrail_flagged
111
+ # RFC-0009: audit events with no OpenAI Responses counterpart. On a BLOCK
112
+ # the safe reply still reaches the consumer through the normal :content
113
+ # deltas + :task_completed path (the turn completes gracefully), so there
114
+ # is nothing extra to translate here — the events live in /v1/events + the
115
+ # Studio + the trace. Explicit (not a fall-through) to keep the closed
116
+ # catalog honest.
117
+ nil
118
+ end
119
+ end
120
+
121
+ # Did the AGENT opt this channel in? The Executor tags the event (`edge_stream`)
122
+ # because this mapper is pure and static — no agent, no stores, no state. An
123
+ # untagged event is internal, which is the default and the safe reading — and
124
+ # "not published" must be nil, like every other unmapped event in the catalog.
125
+ def public_delta(event) = event.data[:public] == true
126
+
127
+ def completed(event)
128
+ response = {}
129
+ if (usage = event.data[:usage])
130
+ # `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
131
+ # usage (pure tokens in usage).
132
+ model = usage[:model] || usage["model"]
133
+ response[:usage] = usage.reject { |k, _| k.to_s == "model" }
134
+ response[:model] = model if model
135
+ end
136
+ # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING; item 34). Absent
137
+ # by default — a non-standard sibling used only for TTFB diagnostics.
138
+ (timing = event.data[:timing]) && (response[:timing] = timing)
139
+ sse("response.completed", { type: "response.completed", response: response })
140
+ end
141
+
142
+ def failed(message)
143
+ sse("response.failed",
144
+ { type: "response.failed", response: { error: { message: message.to_s } } })
145
+ end
146
+
147
+ # event: + data: (the dispatcher reads both: `event:` and `type` in the JSON).
148
+ def sse(event_name, data)
149
+ "event: #{event_name}\ndata: #{JSON.generate(data)}\n\n"
150
+ end
151
+
152
+ def done = "data: [DONE]\n\n"
153
+ end
154
+ end
155
+ 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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+ require "time" # Time#iso8601 for the arrival record on :turn_coalesced
6
+
7
+ module Insika
8
+ # Sessions as Actors: one fiber per session with a FIFO queue
9
+ # of turns, executed ONE AT A TIME. Restores the "one owner at a time"
10
+ # invariant of the transcript that two concurrent `send_message` calls on the
11
+ # same `session_id` would break (read-modify-write on the Session Store). Turns
12
+ # from distinct sessions stay concurrent; one-shot/history (no session_id) do
13
+ # not go through here.
14
+ #
15
+ # Lives in the SUPERVISED scope: the loop is a child of the supervisor, not of
16
+ # the request — it outlives the connection. The turn itself (spawned by the
17
+ # Executor) is also born on the supervisor; the SessionActor only AWAITS it to
18
+ # serialize.
19
+ #
20
+ # RFC-0015: it is also where an inbound message for a BUSY session is routed.
21
+ # That decision belongs here and nowhere else — this is already the object that
22
+ # owns "one turn at a time for this session". Putting it in the HTTP handler
23
+ # would duplicate the invariant; putting it in the Executor would mix turn
24
+ # execution with queue policy.
25
+ class SessionActor
26
+ def initialize(session_id:, executor:, parent: Async::Task.current)
27
+ @session_id = session_id
28
+ @executor = executor
29
+ @queue = Async::Queue.new
30
+ @running = false
31
+ # The turn currently sitting at the door: created and :queued, but not yet
32
+ # released to run. `collect` merges into THIS one. nil whenever there is
33
+ # nothing mergeable — which is the common case and the safe default.
34
+ @pending = nil
35
+ @loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
36
+ end
37
+
38
+ # Enqueues a turn (FIFO). Non-blocking: the handler responds with an
39
+ # immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
40
+ #
41
+ # `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
42
+ # policy without a window behaves exactly as before — dequeued and run at once.
43
+ def enqueue(task, profile:, resume_from: nil, policy: nil)
44
+ @queue.enqueue([task, profile, resume_from, policy])
45
+ task.id
46
+ end
47
+
48
+ # RFC-0015 §5.3 — merge a fragment into the turn waiting at the door.
49
+ # -> the task id it joined, or nil when there is nothing to merge into (no
50
+ # pending turn, the window has closed, or the turn already started). nil is
51
+ # the caller's signal to create a task of its own.
52
+ #
53
+ # Runs on the REQUEST's fiber, not the loop's; both are on the same reactor
54
+ # and neither yields between the check and the write below, so the "is it
55
+ # still mergeable" test and the append cannot interleave.
56
+ def collect(text)
57
+ pending = @pending
58
+ return nil if pending.nil?
59
+
60
+ @executor.task_store.append_message(pending[:task_id], text)
61
+ pending[:count] += 1
62
+ # A merged fragment leaves NO task of its own (see #hold_at_the_door), so this
63
+ # is the only record that it arrived as a separate message. Kept as arrival
64
+ # times — never the text — and shipped on :turn_coalesced, so "the customer
65
+ # says they sent the order number" is answerable without the store carrying an
66
+ # orphan task per fragment.
67
+ pending[:arrivals] << Time.now.utc.iso8601
68
+ pending[:version] += 1 # tells a sleeping debounce window that more arrived
69
+ pending[:task_id]
70
+ rescue ArgumentError
71
+ # The turn left :queued between the read of @pending and the append (it was
72
+ # released while we were deciding). Not an error: the caller falls back to
73
+ # creating its own task, which is exactly `followup`.
74
+ nil
75
+ end
76
+
77
+ def running? = @running
78
+ def depth = @queue.size
79
+
80
+ # The turn this session is running RIGHT NOW, or nil when idle or still at the
81
+ # door. `steer` needs the Task itself and not just its id: whether a turn can
82
+ # absorb a message at all depends on what kind of turn it is (a workflow has no
83
+ # chat), and reading that off the object avoids a store round-trip on the
84
+ # request's path.
85
+ attr_reader :current_task
86
+
87
+ # Is there a turn at the door that `collect` could still merge into?
88
+ def collecting? = !@pending.nil?
89
+
90
+ # Is the loop still alive? (the Executor revalidates before reusing from the
91
+ # cache — a dead loop would black-hole queued turns).
92
+ def alive? = !!@loop&.running?
93
+
94
+ # Shuts down the loop (server shutdown / tests — the loop blocks forever on
95
+ # dequeue when idle).
96
+ def stop = @loop&.stop
97
+
98
+ private
99
+
100
+ def run_loop
101
+ loop do
102
+ task, profile, resume_from, policy = @queue.dequeue # blocks when empty
103
+ task = hold_at_the_door(task, policy)
104
+ @running = true
105
+ @current_task = task
106
+ begin
107
+ @executor.run_serial(task, profile: profile, resume_from: resume_from)
108
+ rescue StandardError
109
+ # run_serial already maps turn errors; this rescue is defense: an
110
+ # unexpected error must NEVER bring down the session loop (Async::Stop <
111
+ # Exception is not captured -> #stop ends the loop normally).
112
+ nil
113
+ ensure
114
+ @running = false
115
+ @current_task = nil
116
+ end
117
+ end
118
+ end
119
+
120
+ # RFC-0015 §5.3 — the debounce window. Sleeps on the LOOP's fiber, never on the
121
+ # request's, so the POST is acked immediately and the platform does not retry.
122
+ # Returns the task to run (re-read from the store when fragments merged into it,
123
+ # since the in-memory Task is a frozen snapshot of an older message).
124
+ def hold_at_the_door(task, policy)
125
+ return task unless policy&.debounce?
126
+
127
+ @pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
128
+ begin
129
+ wait_for_quiet(policy)
130
+ merged = @pending[:count]
131
+ arrivals = @pending[:arrivals]
132
+ ensure
133
+ # The window is closed BEFORE the turn runs, under every exit path: a
134
+ # `collect` that slipped in here would append to a task about to be read.
135
+ @pending = nil
136
+ end
137
+
138
+ return task if merged == 1
139
+
140
+ @executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
141
+ @executor.task_store.find(task.id) || task
142
+ end
143
+
144
+ # Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
145
+ # (`version` moved), until either a slice passes in silence or the total
146
+ # deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
147
+ # keeps typing from postponing their own answer forever.
148
+ def wait_for_quiet(policy)
149
+ quiet = policy.debounce_ms / 1000.0
150
+ deadline = monotonic + (policy.debounce_max_ms / 1000.0)
151
+
152
+ loop do
153
+ mark = @pending[:version]
154
+ Async::Task.current.sleep(quiet)
155
+ break if @pending[:version] == mark # a full slice of silence
156
+ break if monotonic >= deadline
157
+ end
158
+ end
159
+
160
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ end
162
+ end