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,1693 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "digest"
5
+ require "securerandom"
6
+ require "rack/utils"
7
+ require "json"
8
+ require "time"
9
+ require "date"
10
+ require "yaml"
11
+ require_relative "forms"
12
+ require_relative "nav_icons"
13
+ # The live transcript's SSE body. Shared with the server on purpose: one wire format
14
+ # (Event#to_h) for the same EventStream, whichever door the client came through.
15
+ require_relative "../server/sse_body"
16
+
17
+ module Studio
18
+ # Insika Studio — the server-rendered management UI, replacing
19
+ # OpenClaw's agent-studio. FRAMEWORK AT THE EDGE: it's a separate
20
+ # Roda app, mounted under `/studio`; `lib/insika` and `server/` do NOT gain
21
+ # a Roda dependency. It talks to the runtime through the SAME surface as the API:
22
+ # dispatches Commands on the CommandBus and READS profiles/stores — never writes to a store
23
+ # directly (the transport's constitutional rule).
24
+ #
25
+ # SESSION/cookie auth: login compares the token in constant time against
26
+ # `ADMIN_TOKEN`, sets an httpOnly SameSite=Lax cookie and protects `/studio/*`
27
+ # fail-closed (no token configured → login never validates → studio inaccessible).
28
+ # Replaces the manual `LocalAdminShim`/Bearer. CSRF on the POSTs.
29
+ #
30
+ # Same-origin assets: versioned esbuild bundle in `assets/dist/*`, served
31
+ # by `/studio/assets/dist/*`. Strict `'self'` CSP (no `unsafe-inline`).
32
+ class App < Roda
33
+ include Forms # form -> command-payload parsing
34
+ include NavIcons # nav SVG helper
35
+
36
+ # The session cookie lives N days. 7 days = parity with the OpenClaw default.
37
+ SESSION_MAX_AGE = 7 * 24 * 3600
38
+ ASSETS_DIR = File.expand_path("assets/dist", __dir__)
39
+
40
+ CONTENT_TYPES = {
41
+ ".js" => "text/javascript; charset=utf-8",
42
+ ".css" => "text/css; charset=utf-8",
43
+ ".map" => "application/json; charset=utf-8",
44
+ ".woff2" => "font/woff2", ".svg" => "image/svg+xml"
45
+ }.freeze
46
+
47
+ # Plugins that do NOT depend on the secret (loaded at class definition).
48
+ plugin :render, views: File.expand_path("views", __dir__), engine: "erb",
49
+ layout: "layout", escape: true
50
+ plugin :hash_branches
51
+ plugin :h
52
+
53
+ # Strict CSP: no `unsafe-inline`. Everything comes from the same-origin bundle in
54
+ # /studio/assets/dist. `connect-src 'self'` covers the playground's EventSource
55
+ # (SSE from /studio/events, same origin). `img-src data:` covers inline SVG/icons.
56
+ plugin :content_security_policy do |csp|
57
+ csp.default_src :none
58
+ csp.script_src :self
59
+ csp.style_src :self
60
+ csp.img_src :self, "data:"
61
+ csp.font_src :self
62
+ csp.connect_src :self
63
+ csp.form_action :self
64
+ csp.base_uri :none
65
+ csp.frame_ancestors :none
66
+ end
67
+
68
+ class << self
69
+ # Runtime dependencies injected at boot (same surface as Server::App).
70
+ attr_reader :insika
71
+
72
+ # Studio wiring (called by the boot: serve_real / config.ru). Loads the
73
+ # plugins that depend on the secret (sessions/csrf/flash) and stores the deps.
74
+ # An explicit `session_secret` is for the specs; in production it derives from the
75
+ # admin token (stable across restarts, without requiring one more env var).
76
+ #
77
+ # Besides the usual trio (command_bus/profile_source/config), the
78
+ # Studio now READS authoring stores (agent_file/skill/tool/memory/session)
79
+ # to render the pages. All optional (default nil): pages that
80
+ # depend on a store degrade to an empty-state if it was not injected.
81
+ def configure(command_bus:, profile_source:, event_stream:, config:,
82
+ agent_file_store: nil, skill_store: nil, skill_catalog: nil,
83
+ tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
84
+ settings_store: nil, llm_provider_store: nil, mcp_store: nil,
85
+ system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
86
+ task_store: nil, checkpoint_store: nil, pending_action_store: nil,
87
+ refinement_store: nil, golden_store: nil, session_secret: nil)
88
+ @insika = {
89
+ command_bus: command_bus, profile_source: profile_source,
90
+ event_stream: event_stream, config: config,
91
+ agent_file_store: agent_file_store, skill_store: skill_store,
92
+ skill_catalog: skill_catalog, tool_catalog: tool_catalog,
93
+ # tool_store: DATA-DEFINED tool definitions. The catalog already
94
+ # shows the data-tools in the matrix; the store feeds the authoring page.
95
+ tool_store: tool_store,
96
+ memory_store: memory_store, session_store: session_store,
97
+ # runtime config (settings/LLM/MCP) + global system
98
+ # files + conversations index. All optional (empty-state if nil).
99
+ settings_store: settings_store, llm_provider_store: llm_provider_store,
100
+ mcp_store: mcp_store, system_file_store: system_file_store,
101
+ # per-session tool-call trace (debug): args + result + status per
102
+ # turn, rendered in the session viewer.
103
+ tool_trace_store: tool_trace_store,
104
+ # per-session context breakdown: tokens by category +
105
+ # budget per turn, on the same viewer. Counts only, no content.
106
+ context_trace_store: context_trace_store,
107
+ # operate: tasks + human-in-the-loop approvals. Reads the
108
+ # task/checkpoint/pending stores to render; controls (pause/resume/
109
+ # cancel/approve) dispatch on the bus — parity with server/admin.
110
+ task_store: task_store, checkpoint_store: checkpoint_store,
111
+ pending_action_store: pending_action_store,
112
+ # refinement runs: the ranked failure report per agent.
113
+ # Read-only here; the "Run" button dispatches :run_refinement on the bus.
114
+ refinement_store: refinement_store,
115
+ # eval cases: read to render; writes go through:write_golden.
116
+ golden_store: golden_store
117
+ }.freeze
118
+ # "restart recommended" flag — in memory, PER PROCESS. A
119
+ # config change that the runtime only re-reads at boot (e.g.: MCP instances are
120
+ # wired at startup) lights the flag; restarting the process clears it
121
+ # naturally (new process = `configure` runs again = flag reset).
122
+ # Deliberately not put in the session: the session survives the restart (the secret
123
+ # derives from the token) and the flag would stay stuck on.
124
+ @restart_needed = false
125
+ secret = session_secret || derive_secret(config[:admin_token])
126
+ plugin :sessions, key: "insika.studio", secret: secret,
127
+ max_seconds: SESSION_MAX_AGE, same_site: :lax
128
+ # CSRF token bound to the SESSION (not the method+path pair). route_csrf's
129
+ # per-path binding uses `request.path` = POST-MOUNT PATH_INFO ("/login",
130
+ # not "/studio/login"), which would confuse form-action × token under the
131
+ # URLMap. Session-bound is safe for the single-tenant target.
132
+ plugin :route_csrf, require_request_specific_tokens: false, csrf_failure: :empty_403
133
+ plugin :flash
134
+ self
135
+ end
136
+
137
+ # Deterministic per-deploy session secret (>=64 bytes required by Roda
138
+ # sessions). Derives from the admin token → stable across restarts (the session
139
+ # survives) without a new env var; change the token and all sessions drop.
140
+ def derive_secret(admin_token)
141
+ Digest::SHA512.hexdigest("insika-studio-session-v1:#{admin_token}")
142
+ end
143
+
144
+ # --- Restart recommended — per-process state -------------------
145
+ def restart_needed? = @restart_needed == true
146
+ def mark_restart_needed! = (@restart_needed = true)
147
+ def clear_restart_needed! = (@restart_needed = false)
148
+ end
149
+
150
+ route do |r|
151
+ response["x-content-type-options"] = "nosniff"
152
+ response["referrer-policy"] = "same-origin"
153
+
154
+ # Per-request CSP nonce. `style-src 'self'` alone blocks inline <style>, and
155
+ # both CodeMirror (style-mod injects a <style> for its base theme + syntax
156
+ # highlight) and Turbo (the progress-bar <style>) rely on one. Whitelisting a
157
+ # per-response nonce lets exactly those styles load WITHOUT opening the policy to
158
+ # 'unsafe-inline'. The editor passes the same nonce to CodeMirror via
159
+ # EditorView.cspNonce, and Turbo reads it from <meta name="csp-nonce"> — both
160
+ # sourced from the `csp_nonce` helper, so header and markup always agree.
161
+ response.content_security_policy&.add_style_src([:nonce, csp_nonce])
162
+
163
+ # Versioned assets: public (the UI loads the bundle BEFORE login).
164
+ r.on "assets", "dist" do
165
+ r.get String do |name|
166
+ serve_asset(name)
167
+ end
168
+ end
169
+
170
+ # Login: the only path without a session. GET shows the form; POST validates the
171
+ # token in constant time and, if ok, marks the session and redirects.
172
+ r.is "login" do
173
+ r.get { view("login") }
174
+ r.post do
175
+ check_csrf!
176
+ if authenticate(r.params["token"])
177
+ session["auth"] = true
178
+ r.redirect("/studio/agents")
179
+ else
180
+ @error = "Invalid token, or the studio is disabled (set ADMIN_TOKEN)."
181
+ response.status = 401
182
+ view("login")
183
+ end
184
+ end
185
+ end
186
+
187
+ # --- FAIL-CLOSED: from here down requires an authenticated session ----
188
+ r.redirect("/studio/login") unless authenticated?
189
+
190
+ r.post "logout" do
191
+ check_csrf!
192
+ clear_session
193
+ r.redirect("/studio/login")
194
+ end
195
+
196
+ # Dismisses the "restart recommended" banner without restarting (the operator
197
+ # acknowledges and moves on). A real restart clears the flag on its own.
198
+ r.post "restart-ack" do
199
+ check_csrf!
200
+ self.class.clear_restart_needed!
201
+ r.redirect(safe_back(r.params["back"]))
202
+ end
203
+
204
+ # GET /studio/events — the live transcript's SSE, on the STUDIO's own auth.
205
+ # `EventSource` cannot send an Authorization header, so the browser used to read
206
+ # the server's `/v1/events` directly — which is why that route had to stay open to
207
+ # the world, streaming assistant text for any session to anyone who knew the URL.
208
+ # Now the browser talks to the Studio (session cookie, already required above) and
209
+ # `/v1` is machine-only, behind its Bearer. Same process, same EventStream, same
210
+ # wire — only the door changed.
211
+ r.get "events" do
212
+ next_404 unless insika[:event_stream] # no stream wired: the feature is not there
213
+ subscription = insika[:event_stream].subscribe(
214
+ task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"])
215
+ )
216
+ r.halt([200,
217
+ { "content-type" => "text/event-stream", "cache-control" => "no-cache",
218
+ "x-accel-buffering" => "no" },
219
+ Insika::Server::SSEBody.new(subscription: subscription)])
220
+ end
221
+
222
+ # `/studio` and `/studio/` → overview home.
223
+ r.root { r.redirect("/studio/home") }
224
+
225
+ # --- Overview: at-a-glance dashboard ---------------------
226
+ r.on "home" do
227
+ r.is { r.get { render_home } }
228
+ end
229
+
230
+ # --- Agents: list + detail/authoring ---------------------
231
+ r.on "agents" do
232
+ # /studio/agents — agents grid (reads the ProfileSource).
233
+ r.is do
234
+ r.get do
235
+ @agents = insika[:profile_source].all.sort_by(&:id)
236
+ view("agents")
237
+ end
238
+
239
+ # POST /studio/agents — creates an agent ("everyone creates
240
+ # their own BIA"). Fires :create_agent; redirects to the new one's detail.
241
+ r.post do
242
+ check_csrf!
243
+ id = presence(r.params["id"])
244
+ result = with_flash("Agent '#{id}' created.") do
245
+ dispatch(:create_agent, {
246
+ id: id, model: presence(r.params["model"]),
247
+ provider: presence(r.params["provider"]),
248
+ memory: r.params["memory"] == "1"
249
+ })
250
+ end
251
+ r.redirect(result ? agent_path(id) : "/studio/agents")
252
+ end
253
+ end
254
+
255
+ # /studio/agents/:id — an agent's authoring page.
256
+ r.on String do |id|
257
+ id = utf8(id)
258
+ @agent = insika[:profile_source].fetch(id)
259
+ next_404 unless @agent
260
+
261
+ # GET /studio/agents/:id — config + prompts + skills + memory + history.
262
+ r.is do
263
+ r.get { render_agent_detail }
264
+ end
265
+
266
+ # Config/model → :update_agent (patch merge).
267
+ r.post "config" do
268
+ check_csrf!
269
+ with_flash("Configuration saved.") do
270
+ dispatch(:update_agent, config_patch(r))
271
+ end
272
+ r.redirect(agent_path(id))
273
+ end
274
+
275
+ # Store-backed prompts. Writing also ensures the file
276
+ # enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
277
+ # prompt_files is synced by the Commands themselves (write/delete
278
+ # register/remove the file) — the Studio just dispatches the operation.
279
+ r.on "prompts" do
280
+ r.post "delete" do
281
+ check_csrf!
282
+ with_flash("Prompt removed.") do
283
+ dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
284
+ end
285
+ r.redirect(agent_path(id))
286
+ end
287
+
288
+ r.post "restore" do
289
+ check_csrf!
290
+ with_flash("Version restored.") do
291
+ dispatch(:restore_agent_file, {
292
+ agent_id: id, file: presence(r.params["file"]),
293
+ version: r.params["version"]
294
+ })
295
+ end
296
+ r.redirect(agent_path(id))
297
+ end
298
+
299
+ r.post do
300
+ check_csrf!
301
+ with_flash("Prompt saved.") do
302
+ dispatch(:write_agent_file, {
303
+ agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
304
+ })
305
+ end
306
+ r.redirect(agent_path(id))
307
+ end
308
+ end
309
+
310
+ r.on "skills" do
311
+ # POST /studio/agents/:id/skills → :update_agent with the `skills`
312
+ # allowlist. "all" = nil; otherwise the checked subset (possibly []).
313
+ r.is do
314
+ r.post do
315
+ check_csrf!
316
+ skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
317
+ with_flash("Skills updated.") do
318
+ dispatch(:update_agent, { id: id, skills: skills })
319
+ end
320
+ r.redirect(agent_path(id))
321
+ end
322
+ end
323
+
324
+ # /studio/agents/:id/skills/:name — this agent's OWN version of a skill.
325
+ # TWO path segments, which is exactly why the store takes the agent as a
326
+ # second argument: a composite "agent/name" key would put a `/` inside
327
+ # what this route serves as one segment.
328
+ r.on String do |name|
329
+ name = utf8(name)
330
+ r.is do
331
+ r.get do
332
+ load_skills_master(agent: id)
333
+ @selected = name
334
+ @scope_agent = id
335
+ @skill_content = skill_source(name, agent: id)
336
+ view("skills")
337
+ end
338
+ # Saving the specialization: same command, `agent:` set.
339
+ r.post do
340
+ check_csrf!
341
+ with_flash("Specialization saved.") do
342
+ dispatch(:write_skill, { name: name, agent: id, content: r.params["content"].to_s })
343
+ end
344
+ r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
345
+ end
346
+ end
347
+
348
+ # Un-specialize: the shared skill stays, and the agent falls back to it.
349
+ r.post "delete" do
350
+ check_csrf!
351
+ with_flash("Specialization removed — the agent falls back to the shared skill.") do
352
+ dispatch(:delete_skill, { name: name, agent: id })
353
+ end
354
+ r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
355
+ end
356
+ end
357
+ end
358
+
359
+ # Agent memory. Scoped by tenant = agent id — the
360
+ # SAME tenant the playground uses when chatting, so what is edited
361
+ # here is what the BIA reads on the turn. Each agent, its own memory.
362
+ r.on "memory" do
363
+ r.post "fact" do
364
+ check_csrf!
365
+ with_flash("Fact saved.") do
366
+ dispatch(:memory_put_fact, {
367
+ tenant: id, key: presence(r.params["key"]), value: r.params["value"].to_s
368
+ })
369
+ end
370
+ r.redirect(agent_path(id, "memory"))
371
+ end
372
+ r.post "forget" do
373
+ check_csrf!
374
+ with_flash("Fact forgotten.") do
375
+ dispatch(:memory_forget_fact, { tenant: id, key: presence(r.params["key"]) })
376
+ end
377
+ r.redirect(agent_path(id, "memory"))
378
+ end
379
+ r.post "note" do
380
+ check_csrf!
381
+ with_flash("Note added.") do
382
+ dispatch(:memory_add_note, { tenant: id, text: r.params["text"].to_s })
383
+ end
384
+ r.redirect(agent_path(id, "memory"))
385
+ end
386
+ end
387
+ end
388
+ end
389
+
390
+ # --- Skills: catalog + agents matrix + editor ----------------
391
+ r.on "skills" do
392
+ r.is do
393
+ r.get { render_skills_index }
394
+ # POST /studio/skills → writes the skill (full SKILL.md) and reloads
395
+ # the catalog (hot). Covers "new skill" and "save edit".
396
+ r.post do
397
+ check_csrf!
398
+ name = presence(r.params["name"])
399
+ with_flash("Skill saved.") do
400
+ dispatch(:write_skill, { name: name, content: r.params["content"].to_s })
401
+ end
402
+ r.redirect(name ? "/studio/skills/#{Rack::Utils.escape(name)}" : "/studio/skills")
403
+ end
404
+ end
405
+
406
+ # New skill editor (before the generic String matcher).
407
+ r.get "new" do
408
+ load_skills_master
409
+ @selected = ""
410
+ @skill_content = new_skill_template
411
+ view("skills")
412
+ end
413
+
414
+ r.on String do |name|
415
+ name = utf8(name)
416
+ # GET /studio/skills/:name — drill with this skill open in the detail.
417
+ r.is do
418
+ r.get do
419
+ load_skills_master
420
+ @selected = name
421
+ @skill_content = skill_source(name)
422
+ view("skills")
423
+ end
424
+ end
425
+ # Enables/disables the skill on N agents at once, and marks it always-on for
426
+ # the ones that checked `eager`. Both are per-agent decisions about the same
427
+ # skill, so they are the same form and the same dispatch.
428
+ r.post "agents" do
429
+ check_csrf!
430
+ agent_ids = Array(r.params["agent_ids"]).map(&:to_s)
431
+ eager_ids = Array(r.params["eager_ids"]).map(&:to_s)
432
+ result = with_flash("Skill's agents updated.") do
433
+ dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids, eager_ids: eager_ids })
434
+ end
435
+ note_skipped(result)
436
+ r.redirect("/studio/skills")
437
+ end
438
+
439
+ # Seeds a per-agent override from the SHARED body and opens it for editing.
440
+ # Seeded and not blank: specializing means "this, but for me" — starting from
441
+ # an empty editor is how the two versions drift apart on day one.
442
+ r.post "specialize" do
443
+ check_csrf!
444
+ agent_id = presence(r.params["agent_id"])
445
+ with_flash("Specialized for #{agent_id}.") do
446
+ dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
447
+ end
448
+ r.redirect("/studio/agents/#{Rack::Utils.escape(agent_id.to_s)}/skills/#{Rack::Utils.escape(name)}")
449
+ end
450
+ end
451
+ end
452
+
453
+ # --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
454
+ r.on "tools" do
455
+ # Data-defined tool authoring. Under /tools/def/* — BEFORE the
456
+ # generic `r.post String` matcher (which is the allow/deny matrix per agent :id).
457
+ r.on "def" do
458
+ # /studio/tools/def/new — empty editor.
459
+ r.get "new" do
460
+ render_tool_edit(name: "", tool: nil)
461
+ end
462
+
463
+ # POST /studio/tools/def — creates (create_only: refuses to overwrite).
464
+ r.is do
465
+ r.post do
466
+ check_csrf!
467
+ name = presence(r.params["name"])
468
+ result = with_flash("Tool '#{name}' created.") do
469
+ dispatch(:write_data_tool, tool_patch(r).merge(create_only: true))
470
+ end
471
+ r.redirect(result ? tool_def_path(name) : "/studio/tools")
472
+ end
473
+ end
474
+
475
+ r.on String do |name|
476
+ name = utf8(name)
477
+ # GET /studio/tools/def/:name — loaded editor (secret masked).
478
+ r.is do
479
+ r.get do
480
+ tool = insika[:tool_store]&.get(name)
481
+ next_404 unless tool
482
+ render_tool_edit(name: name, tool: tool)
483
+ end
484
+ # POST /studio/tools/def/:name — updates (upsert).
485
+ r.post do
486
+ check_csrf!
487
+ # The stored definition rides along so the fields this form does not
488
+ # render (group/tags/halt_when) survive the save — see UNEDITED_TOOL_FIELDS.
489
+ stored = insika[:tool_store]&.get(name)
490
+ with_flash("Tool '#{name}' saved.") { dispatch(:write_data_tool, tool_patch(r, stored)) }
491
+ r.redirect(tool_def_path(name))
492
+ end
493
+ end
494
+ r.post "delete" do
495
+ check_csrf!
496
+ with_flash("Tool '#{name}' removed.") { dispatch(:delete_data_tool, { name: name }) }
497
+ r.redirect("/studio/tools")
498
+ end
499
+ r.post "restore" do
500
+ check_csrf!
501
+ with_flash("Version restored.") do
502
+ dispatch(:restore_data_tool, { name: name, index: r.params["index"] })
503
+ end
504
+ r.redirect(tool_def_path(name))
505
+ end
506
+ end
507
+ end
508
+
509
+ r.is { r.get { render_tools_matrix } }
510
+
511
+ # POST /studio/tools/:id — writes an agent's tools allowlist.
512
+ # "all" = nil; otherwise the checked subset. `deny` is preserved.
513
+ r.post String do |id|
514
+ check_csrf!
515
+ id = utf8(id)
516
+ profile = insika[:profile_source].fetch(id)
517
+ next_404 unless profile
518
+ allow = r.params["all_tools"] == "1" ? nil : Array(r.params["tools"]).map(&:to_s)
519
+ with_flash("Agent '#{id}' tools updated.") do
520
+ dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny) })
521
+ end
522
+ r.redirect("/studio/tools?a=#{Rack::Utils.escape(id)}")
523
+ end
524
+ end
525
+
526
+ # --- General settings + LLM providers ------------------------
527
+ r.on "settings" do
528
+ r.is do
529
+ r.get { render_settings }
530
+ # General settings (streaming/timeouts) → :update_settings.
531
+ r.post do
532
+ check_csrf!
533
+ with_flash("Settings saved.") do
534
+ dispatch(:update_settings, { patch: settings_patch(r) })
535
+ end
536
+ r.redirect("/studio/settings")
537
+ end
538
+ end
539
+
540
+ # Platform model defaults (sub-resource, v2): its own form/route so a
541
+ # general-settings save never clobbers the model layer.
542
+ r.post "models" do
543
+ check_csrf!
544
+ with_flash("Model defaults saved.") do
545
+ dispatch(:update_settings, { patch: model_defaults_patch(r) })
546
+ end
547
+ r.redirect("/studio/settings?s=models")
548
+ end
549
+
550
+ # Per-model reasoning defaults (the per-model layer): its own form so a
551
+ # model-defaults save never clobbers the per-model map, and vice-versa.
552
+ r.post "model-params" do
553
+ check_csrf!
554
+ with_flash("Per-model params saved.") do
555
+ dispatch(:update_settings, { patch: model_params_patch(r) })
556
+ end
557
+ r.redirect("/studio/settings?s=models")
558
+ end
559
+
560
+ # Edge limits: the platform rate-limit/cost layer.
561
+ # Its own form for the same reason as models — saves never cross-clobber.
562
+ r.post "edge" do
563
+ check_csrf!
564
+ with_flash("Edge limits saved.") do
565
+ dispatch(:update_settings, { patch: edge_patch(r) })
566
+ end
567
+ r.redirect("/studio/settings?s=edge")
568
+ end
569
+
570
+ # Evals: the judge PANEL and how it agrees. Its own
571
+ # form, like models and edge — a save here must not clobber those.
572
+ r.post "evals" do
573
+ check_csrf!
574
+ with_flash("Evals settings saved.") do
575
+ dispatch(:update_settings, { patch: evals_patch(r) })
576
+ end
577
+ r.redirect("/studio/settings?s=evals")
578
+ end
579
+
580
+ # LLM providers (sub-resource): CRUD with masked api_key (sentinel).
581
+ r.on "providers" do
582
+ r.post "delete" do
583
+ check_csrf!
584
+ with_flash("Provider removed.") do
585
+ dispatch(:delete_llm_provider, { api: presence(r.params["api"]) })
586
+ end
587
+ r.redirect("/studio/settings?s=llm")
588
+ end
589
+ r.post do
590
+ check_csrf!
591
+ with_flash("Provider saved.") do
592
+ dispatch(:upsert_llm_provider, provider_patch(r))
593
+ end
594
+ r.redirect("/studio/settings?s=llm")
595
+ end
596
+ end
597
+ end
598
+
599
+ # --- MCP: instances with masked credentials ------------------
600
+ r.on "mcp" do
601
+ r.is do
602
+ r.get { render_mcp }
603
+ r.post do
604
+ check_csrf!
605
+ with_flash("MCP instance saved.") do
606
+ dispatch(:upsert_mcp, mcp_patch(r))
607
+ # MCP servers are wired at runtime boot; the new instance
608
+ # only takes effect after a restart. Lights the "restart" banner.
609
+ self.class.mark_restart_needed!
610
+ end
611
+ r.redirect("/studio/mcp")
612
+ end
613
+ end
614
+ r.post "delete" do
615
+ check_csrf!
616
+ with_flash("MCP instance removed.") do
617
+ dispatch(:delete_mcp, { name: presence(r.params["name"]) })
618
+ self.class.mark_restart_needed!
619
+ end
620
+ r.redirect("/studio/mcp")
621
+ end
622
+ end
623
+
624
+ # --- Global system files -------------------------------------
625
+ # Apply to ALL agents (the Prompt provider injects them before the
626
+ # individual identity). Code-editor + versions, like the prompts.
627
+ r.on "system-files" do
628
+ r.is do
629
+ r.get { render_system_files }
630
+ r.post do
631
+ check_csrf!
632
+ with_flash("System file saved.") do
633
+ dispatch(:write_system_file, {
634
+ file: presence(r.params["file"]), content: r.params["content"].to_s
635
+ })
636
+ end
637
+ r.redirect("/studio/system-files")
638
+ end
639
+ end
640
+ r.post "delete" do
641
+ check_csrf!
642
+ with_flash("File removed.") do
643
+ dispatch(:delete_system_file, { file: presence(r.params["file"]) })
644
+ end
645
+ r.redirect("/studio/system-files")
646
+ end
647
+ r.post "restore" do
648
+ check_csrf!
649
+ with_flash("Version restored.") do
650
+ dispatch(:restore_system_file, {
651
+ file: presence(r.params["file"]), version: r.params["version"]
652
+ })
653
+ end
654
+ r.redirect("/studio/system-files")
655
+ end
656
+ end
657
+
658
+ # --- Chats: conversations index ------------------------------
659
+ # Read-only: lists sessions and links to the existing viewer (/sessions/:id).
660
+ r.on "chats" do
661
+ r.is { r.get { render_chats } }
662
+ end
663
+
664
+ # --- History: read-only viewer of a session ------------------
665
+ r.on "sessions" do
666
+ r.on String do |sid|
667
+ sid = utf8(sid)
668
+ r.get do
669
+ @session = insika[:session_store]&.find(sid)
670
+ next_404 unless @session
671
+
672
+ # Session's tool-call trace (debug): grouped by turn in the view.
673
+ @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
674
+ .group_by { |t| t["turn"] }
675
+ # Context breakdown per turn: chronological entries.
676
+ @context_traces = insika[:context_trace_store]&.for_session(sid) || []
677
+ view("session")
678
+ end
679
+ end
680
+ end
681
+
682
+ # Tasks: list + detail + operator controls -------
683
+ # Parity with server/admin: READS the task/checkpoint/pending stores to
684
+ # render; pause/resume/cancel dispatch Commands on the bus (never a direct
685
+ # store write). Every control audits the ATTEMPT to the EventStream first.
686
+ r.on "tasks" do
687
+ r.is { r.get { render_tasks } }
688
+
689
+ r.on String do |id|
690
+ id = utf8(id)
691
+ r.get do
692
+ @task = insika[:task_store]&.find(id)
693
+ next_404 unless @task
694
+
695
+ render_task_detail(id)
696
+ end
697
+
698
+ r.post "pause" do
699
+ check_csrf!
700
+ control_action(:pause_task, { task_id: id }, ok: "Task paused.")
701
+ r.redirect(task_path(id))
702
+ end
703
+ r.post "resume" do
704
+ check_csrf!
705
+ control_action(:resume_task, { task_id: id }, ok: "Task resumed.")
706
+ r.redirect(task_path(id))
707
+ end
708
+ r.post "cancel" do
709
+ check_csrf!
710
+ control_action(:cancel_task, { task_id: id }, ok: "Task cancelled.")
711
+ r.redirect(task_path(id))
712
+ end
713
+ end
714
+ end
715
+
716
+ # Approvals: human-in-the-loop inbox -------------
717
+ # Lists every :pending action across tasks; resolving one dispatches
718
+ # :approve_action, which resolves the store AND wakes the suspended turn.
719
+ r.on "approvals" do
720
+ r.is { r.get { render_approvals } }
721
+
722
+ r.on String do |pid|
723
+ pid = utf8(pid)
724
+ r.post do
725
+ check_csrf!
726
+ decision = r.params["decision"] == "rejected" ? "rejected" : "approved"
727
+ control_action(:approve_action,
728
+ { pending_id: pid, decision: decision, operator: operator_label },
729
+ ok: "Approval #{decision}.")
730
+ r.redirect(safe_back(r.params["back"], default: "/studio/approvals"))
731
+ end
732
+ end
733
+ end
734
+
735
+ # Evals: the cases that grade an agent -------
736
+ # A case is DATA in the same YAML shape the corpus files use, so what an operator
737
+ # edits here is what a pull request would review. The one loader validates it, on
738
+ # the way in — a malformed case is a red flash, never a silently skipped test.
739
+ r.on "evals" do
740
+ r.is do
741
+ r.get { render_evals }
742
+ r.post do
743
+ check_csrf!
744
+ with_flash("Case saved.") { dispatch(:write_golden, golden_patch(r)) }
745
+ r.redirect("/studio/evals?id=#{Rack::Utils.escape(presence(r.params['id']).to_s)}")
746
+ end
747
+ end
748
+
749
+ r.on String do |id|
750
+ id = utf8(id)
751
+ r.post "delete" do
752
+ check_csrf!
753
+ with_flash("Case removed.") { dispatch(:delete_golden, { id: id }) }
754
+ r.redirect("/studio/evals")
755
+ end
756
+ end
757
+ end
758
+
759
+ # --- Refinement: what broke in real traffic, and what to do about it --
760
+ # `POST /refinement` runs the report. `POST
761
+ # refinement/propose` is: the configured model writes a candidate from
762
+ # the findings and the gate scores it by replaying the golden set. `POST
763
+ # /refinement/resolve` is a human approving or rejecting what the gate passed.
764
+ # All three go through the bus like every other Studio write — this page reads
765
+ # stores and dispatches Commands, nothing else.
766
+ #
767
+ # There is still no form to hand-AUTHOR a candidate: a JSON textarea would be a
768
+ # worse way to say what the API already says, and the button below is what an
769
+ # operator actually wants standing there.
770
+ r.on "refinement" do
771
+ r.is do
772
+ r.get { render_refinement }
773
+ r.post do
774
+ check_csrf!
775
+ agent = presence(r.params["agent"])
776
+ payload = { agent: agent, full: r.params["full"] == "1" }
777
+ control_action(:run_refinement, payload, ok: "Refinement run finished.")
778
+ r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
779
+ end
780
+ end
781
+
782
+ # Propose + gate in one press, because they are one decision for the operator
783
+ # ("try to fix this") and splitting them would park a run holding an unscored
784
+ # candidate — a state nobody can act on. Slow on purpose: the gate replays the
785
+ # golden set, so this returns when the answer is real.
786
+ r.post "propose" do
787
+ check_csrf!
788
+ agent = presence(r.params["agent"])
789
+ payload = { run_id: presence(r.params["run_id"]), propose: true }
790
+ control_action(:gate_refinement, payload, ok: "Proposal gated — see the result below.")
791
+ r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
792
+ end
793
+
794
+ r.post "resolve" do
795
+ check_csrf!
796
+ agent = presence(r.params["agent"])
797
+ decision = presence(r.params["decision"])
798
+ payload = { run_id: presence(r.params["run_id"]), decision: decision,
799
+ operator: "studio", note: presence(r.params["note"]) }.compact
800
+ ok = decision == "approved" ? "Applied — the agent's files were updated." : "Proposal rejected."
801
+ control_action(:resolve_refinement, payload, ok: ok)
802
+ r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
803
+ end
804
+ end
805
+
806
+ # Playground: sends `send_message` (the SAME Command as the API) and streams the
807
+ # response live through the `live-transcript` island (SSE from /studio/events).
808
+ r.on "playground" do
809
+ r.get do
810
+ @agent = presence(r.params["agent"]) || default_agent
811
+ @session_id = presence(r.params["session_id"])
812
+ @agents = insika[:profile_source].ids.sort
813
+ # Server-side echo + continuity: render the session's persisted
814
+ # transcript as bubbles. The user's message is only persisted at the END
815
+ # of the turn, so the just-sent message rides a one-shot flash bubble
816
+ # (@sent_message) until it lands in history — an optimistic JS echo can't
817
+ # work here (POST→redirect wipes the DOM).
818
+ @history = @session_id ? Array(insika[:session_store]&.find(@session_id)&.messages) : []
819
+ @sent_message = flash["sent_message"]
820
+ view("playground")
821
+ end
822
+ r.post do
823
+ check_csrf!
824
+ agent = presence(r.params["agent"]) || default_agent
825
+ typed_session = presence(r.params["session_id"])
826
+ message = r.params["message"].to_s
827
+ # Blank session = new conversation: created via Command (create_session
828
+ # generates the id — the Studio doesn't write to the store directly). A typed id
829
+ # continues an existing conversation (send_message requires it to exist).
830
+ # The per-chat model pin is set at creation and rides the whole
831
+ # conversation, so it only applies to a NEW session — an existing one keeps
832
+ # whatever it was pinned to.
833
+ session_id = typed_session ||
834
+ create_session(model: presence(r.params["model"]),
835
+ provider: presence(r.params["provider"]),
836
+ thinking: presence(r.params["thinking"]))
837
+ dispatch_send_message(agent: agent, session_id: session_id, message: message)
838
+ # Optimistic echo of the just-sent message: survives the redirect
839
+ # as a one-shot flash, rendered as a user bubble on the next GET.
840
+ flash["sent_message"] = message unless message.empty?
841
+ r.redirect(playground_path(agent, session_id))
842
+ rescue Insika::ValidationError, Insika::NotFoundError => e
843
+ flash["error"] = e.message
844
+ r.redirect(playground_path(agent, typed_session))
845
+ end
846
+ end
847
+
848
+ # Unknown authenticated route → friendly 404 (not Roda's empty body).
849
+ response.status = 404
850
+ view("not_found")
851
+ end
852
+
853
+ private
854
+
855
+ # --- Helpers (instance scope; available inside the views) ----------------
856
+
857
+ def insika = self.class.insika
858
+
859
+ # Sidebar navigation, grouped by operator intent (build / runtime /
860
+ # operate). Each item: [label, href, icon-key]. The view marks the active item
861
+ # by comparing the path and renders the icon via `nav_icon`.
862
+ def nav_sections
863
+ [
864
+ ["", [
865
+ ["Home", "/studio/home", :home]
866
+ ]],
867
+ ["build", [
868
+ ["Agents", "/studio/agents", :agents],
869
+ ["Skills", "/studio/skills", :skills],
870
+ ["Tools", "/studio/tools", :tools],
871
+ ["System files", "/studio/system-files", :system]
872
+ ]],
873
+ ["runtime", [
874
+ ["MCP", "/studio/mcp", :mcp],
875
+ ["Settings", "/studio/settings", :settings]
876
+ ]],
877
+ ["operate", [
878
+ ["Chats", "/studio/chats", :chats],
879
+ ["Playground", "/studio/playground", :playground],
880
+ ["Tasks", "/studio/tasks", :tasks],
881
+ ["Approvals", "/studio/approvals", :approvals],
882
+ ["Refinement", "/studio/refinement", :refinement],
883
+ ["Evals", "/studio/evals", :evals]
884
+ ]]
885
+ ]
886
+ end
887
+
888
+ # NAV_ICONS + nav_icon moved to Studio::NavIcons.
889
+
890
+ def authenticated? = session["auth"] == true
891
+
892
+ # Is the given nav href the current page? Robust to whether request.path
893
+ # carries the "/studio" mount prefix (it does under serve_real/config.ru, it
894
+ # doesn't in specs) — strip it from both sides, then exact- or prefix-match
895
+ # so detail routes (/agents/:id) still light up their section.
896
+ def nav_active?(href)
897
+ target = href.sub(%r{\A/studio}, "")
898
+ path = request.path.sub(%r{\A/studio}, "")
899
+ path == target || path.start_with?("#{target}/")
900
+ end
901
+
902
+ # CSP nonce for inline styles (CodeMirror's injected theme). Stable PER SESSION,
903
+ # not per request: the browser enforces the CSP from the initial document load
904
+ # for the whole SPA session, but Turbo Drive fetches later pages carrying their
905
+ # OWN nonce in the <meta>. A per-request nonce would therefore never match what
906
+ # the browser enforces after a Turbo navigation, so CodeMirror's <style> gets
907
+ # blocked and the editor renders unstyled/broken until a full reload. A
908
+ # session-lifetime nonce keeps the meta constant across Turbo visits (matches the
909
+ # enforced value) while still being unguessable and per-user — the standard
910
+ # Turbo+CSP reconciliation. Scripts stay 'self' (no nonce), so this only governs
911
+ # styles. Memoized on the request instance so header and <meta> agree.
912
+ def csp_nonce = (@csp_nonce ||= (session["csp_nonce"] ||= SecureRandom.base64(16)))
913
+
914
+ # --- Polish: theme, health chip, restart banner ----------------
915
+
916
+ def restart_needed? = self.class.restart_needed?
917
+
918
+ # Environment label for the sidebar identity chip — reflects the REAL runtime
919
+ # env (INSIKA_ENV → RACK_ENV → "local") so an operator always knows which box
920
+ # they are looking at. No new state; just reads the process env.
921
+ def env_label = (presence(Insika::EnvSchema.read("INSIKA_ENV")) || presence(ENV["RACK_ENV"]) || "local").to_s
922
+
923
+ # Theme preference read from the cookie (applied server-side on <html> → no
924
+ # flash). Strict allowlist: an unexpected value falls back to "auto".
925
+ THEMES = %w[auto light dark].freeze
926
+ def theme_pref
927
+ value = request.cookies["insika.theme"].to_s
928
+ THEMES.include?(value) ? value : "auto"
929
+ end
930
+
931
+ # Structured counts for the sidebar health card: [label, value]. Only what
932
+ # the Studio already reads — no new ping. Persistence (durable/ephemeral)
933
+ # comes from config, if boot supplied it (serve_real does; specs don't need).
934
+ def health_parts
935
+ parts = [["agents", insika[:profile_source].all.size]]
936
+ parts << ["LLM providers", insika[:llm_provider_store].all.size] if insika[:llm_provider_store]
937
+ parts << ["MCP servers", insika[:mcp_store].all.size] if insika[:mcp_store]
938
+ persistence = insika[:config][:persistence]
939
+ parts << ["persistence", persistence.to_s] if persistence && !persistence.to_s.empty?
940
+ parts
941
+ end
942
+
943
+ # Only redirects to a LOCAL path (avoids open-redirect via `back`):
944
+ # starts with "/", but not "//" (protocol-relative) nor contains a scheme.
945
+ def safe_back(path, default: "/studio/agents")
946
+ p = presence(path)
947
+ return default unless p&.start_with?("/")
948
+ return default if p.start_with?("//") || p.include?("://") || p.include?("\\")
949
+
950
+ p
951
+ end
952
+
953
+ # Fail-closed BY CONSTRUCTION (AdminAuth parity): with no token configured, the
954
+ # compare never passes → studio inaccessible. Constant-time comparison.
955
+ def authenticate(provided)
956
+ configured = insika[:config][:admin_token].to_s
957
+ provided = provided.to_s
958
+ return false if configured.empty? || provided.empty?
959
+
960
+ Rack::Utils.secure_compare(configured, provided)
961
+ end
962
+
963
+ def default_agent
964
+ ids = insika[:profile_source].ids
965
+ ids.include?("bia") ? "bia" : (ids.first || "bia")
966
+ end
967
+
968
+ # --- Transcript display helpers (playground + session viewer) ------------
969
+
970
+ # A stored message's content for display: strings as-is; structured payloads
971
+ # as pretty JSON. NEVER Ruby #inspect (session.erb:45 leaked hashrockets to the
972
+ # operator)..
973
+ def message_content(content)
974
+ return content.to_s if content.is_a?(String)
975
+
976
+ JSON.pretty_generate(content)
977
+ rescue StandardError
978
+ content.to_s
979
+ end
980
+
981
+ # An activation label from the context trace, as the operator reads it:
982
+ # "gift-concierge · trigger:presente". The REASON is the point of the card — a
983
+ # bare list of names answers "was something injected", never "which one did I
984
+ # trigger". A label with no reason (a plugin-supplied body) shows just the name.
985
+ def skill_label(label)
986
+ return label.to_s unless label.is_a?(Hash)
987
+
988
+ name = label["name"].to_s
989
+ reason = label["reason"].to_s
990
+ reason.empty? ? name : "#{name} · #{reason}"
991
+ end
992
+
993
+ # One word for the whole turn, for the collapsed summary: the shared reason when
994
+ # every body arrived the same way, "mixed" when they did not. A turn CAN mix (the
995
+ # agent's eager set plus what this message triggered), which is exactly why the
996
+ # single per-turn `mode` this replaced was a lie waiting to happen.
997
+ def skill_mode(labels)
998
+ kinds = Array(labels).map { |l| (l.is_a?(Hash) ? l["reason"].to_s : "").split(":").first }.uniq
999
+ return "context" if kinds.empty? || kinds.any?(&:nil?) || kinds.include?("")
1000
+
1001
+ kinds.length == 1 ? kinds.first : "mixed"
1002
+ end
1003
+
1004
+ # ISO8601 → "HH:MM" for a transcript timestamp; "" when unparseable/absent.
1005
+ def short_time(iso)
1006
+ Time.parse(iso.to_s).strftime("%H:%M")
1007
+ rescue StandardError
1008
+ ""
1009
+ end
1010
+
1011
+ # Friendly 404 from any point in the routing (missing agent/session).
1012
+ # `throw :halt` with the response already assembled (Roda pattern).
1013
+ def next_404
1014
+ response.status = 404
1015
+ response.write(view("not_found"))
1016
+ request.halt
1017
+ end
1018
+
1019
+ # Roda captures path segments with ASCII-8BIT (binary) encoding. The Store
1020
+ # keys were written in UTF-8; a `get` with a binary string does NOT match in the
1021
+ # SQLite backend (binds as BLOB) and would even write a duplicate key if it
1022
+ # entered a write payload. Normalizes at the ONLY point where the binary
1023
+ # is born — the Roda edge — so the core (framework-agnostic) only sees
1024
+ # UTF-8 strings. The bytes come from the already-decoded URL (UTF-8), so
1025
+ # force_encoding is correct, not a reinterpretation.
1026
+ def utf8(str) = Insika::Coercion.utf8(str)
1027
+
1028
+ # Dispatches a Command through the bus (same surface as the API) and returns the
1029
+ # result. `tenant` is only used by memory.
1030
+ def dispatch(type, payload, tenant: nil)
1031
+ insika[:command_bus].dispatch(
1032
+ Insika::Command.build(type, payload, transport: :studio, tenant: tenant)
1033
+ )
1034
+ end
1035
+
1036
+ # Wraps a write dispatch with a success/error flash and returns the
1037
+ # result (or nil on error). Domain errors (Validation/NotFound) become a
1038
+ # red flash — never a 500 in the user's face.
1039
+ def with_flash(success)
1040
+ result = yield
1041
+ flash["notice"] = success
1042
+ result
1043
+ rescue Insika::ValidationError, Insika::NotFoundError => e
1044
+ flash["error"] = e.message
1045
+ nil
1046
+ end
1047
+
1048
+ # --- Agent detail read ---------------------------------------------------
1049
+
1050
+ def render_agent_detail
1051
+ id = @agent.id
1052
+ store = insika[:agent_file_store]
1053
+ @prompt_files = Array(@agent.prompt_files).map do |name|
1054
+ {
1055
+ name: name.to_s,
1056
+ content: store&.read(id, name).to_s,
1057
+ versions: store ? store.versions(id, name) : []
1058
+ }
1059
+ end
1060
+ @all_skills = insika[:skill_catalog]&.all || []
1061
+ @agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
1062
+ # v2 config surfaces: generation params + model fence. AgentProfile.build
1063
+ # string-keys these hashes, so the form helpers read plain string keys.
1064
+ @params = @agent.params
1065
+ @model_policy_allow = model_policy_allow(@agent)
1066
+ # guardrails config; nil when the agent never configured any.
1067
+ @guardrails = @agent.guardrails || {}
1068
+ mem = insika[:memory_store]
1069
+ @facts = mem ? mem.facts(tenant: id) : []
1070
+ @notes = mem ? mem.notes(tenant: id, limit: 20) : []
1071
+ @recent_sessions = recent_sessions
1072
+ view("agent_detail")
1073
+ end
1074
+
1075
+ # A generation param off the profile (string-keyed by AgentProfile.build). Used
1076
+ # to pre-fill the config form (empty string when absent).
1077
+ def agent_param(params, key)
1078
+ return "" unless params.is_a?(Hash)
1079
+
1080
+ params[key.to_s].nil? ? "" : params[key.to_s]
1081
+ end
1082
+
1083
+ # Renders the reasoning <select> shared by the agent config, settings and
1084
+ # playground (4-layer). `blank_label` names the empty option — the
1085
+ # "inherit the broader layer" / provider-default choice. Values come from a
1086
+ # fixed constant (safe to emit); `current` is only compared, never output.
1087
+ def thinking_select(name, current, blank_label)
1088
+ cur = current.to_s
1089
+ options = [["", blank_label]] + Insika::ModelSelection::THINKING_LEVELS.map { |v| [v, v] }
1090
+ rows = options.map do |value, label|
1091
+ %(<option value="#{value}"#{' selected' if value == cur}>#{label}</option>)
1092
+ end.join
1093
+ %(<select name="#{name}">#{rows}</select>)
1094
+ end
1095
+
1096
+ # Renders the per-model reasoning map ({ "ref" => { "thinking" => v } }) back to
1097
+ # the textarea lines "ref | thinking" that model_params_patch parses. Only refs
1098
+ # with a set thinking are shown (blank ones are inherit no-ops).
1099
+ def model_params_text(map)
1100
+ return "" unless map.is_a?(Hash)
1101
+
1102
+ map.filter_map do |ref, cfg|
1103
+ t = cfg.is_a?(Hash) ? cfg["thinking"] : nil
1104
+ "#{ref} | #{t}" if t
1105
+ end.join("\n")
1106
+ end
1107
+
1108
+ # A guardrails field off the profile (string-keyed by AgentProfile.build).
1109
+ # `default` is returned when the whole config or the key is absent (so a
1110
+ # never-configured agent shows the conservative defaults in the form).
1111
+ def guardrail_field(gr, key, default)
1112
+ return default unless gr.is_a?(Hash)
1113
+
1114
+ gr[key.to_s].nil? ? default : gr[key.to_s]
1115
+ end
1116
+
1117
+ # The agent's model fence as newline-joined refs (for the textarea). nil / no
1118
+ # allow list -> "" (no fence).
1119
+ def model_policy_allow(agent)
1120
+ policy = agent.model_policy
1121
+ return "" unless policy.is_a?(Hash)
1122
+
1123
+ Array(policy["allow"]).join("\n")
1124
+ end
1125
+
1126
+ # Config patch from the form (native types: memory bool, limits int).
1127
+ # Preserves the existing limits, overwriting only the form's fields.
1128
+ # `model` is OPTIONAL as of v2: blank clears it, so the agent inherits
1129
+ # the platform `default_model` via the ModelResolver — the config form is the
1130
+ # place that surfaces that layering. params/model_policy: see the helpers below.
1131
+ # config_patch/guardrails_patch/guardrail_responses_patch/params_patch/
1132
+ # model_policy_patch/coerce moved to Studio::Forms.
1133
+
1134
+ # --- Skills index --------------------------------------------------------
1135
+
1136
+ # Master data for the Skills drill-down (list + authored badges + agents),
1137
+ # shared by every skill route so the master pane always renders. @selected
1138
+ # drives the detail pane (nil = none, "" = new, name = edit).
1139
+ # `agent` renders the AGENT's view of the catalog: its own version of each shared
1140
+ # skill, plus whatever is private to it. Absent = the shared catalog.
1141
+ def load_skills_master(agent: nil)
1142
+ catalog = insika[:skill_catalog]
1143
+ @skills = (catalog ? catalog.all(agent: agent) : []).sort_by(&:name)
1144
+ @stored = insika[:skill_store] ? insika[:skill_store].names(agent: agent) : []
1145
+ @agents = insika[:profile_source].all.sort_by(&:id)
1146
+ # Which agents specialized THIS skill — the availability grid shows it, so an
1147
+ # override is discoverable from the shared skill it overrides.
1148
+ @specialized = insika[:skill_store] ? specialized_by : {}
1149
+ end
1150
+
1151
+ # { skill name => [agent ids] } across every agent scope in the store.
1152
+ def specialized_by
1153
+ insika[:skill_store].agents.each_with_object({}) do |agent, acc|
1154
+ insika[:skill_store].names(agent: agent).each { |name| (acc[name] ||= []) << agent }
1155
+ end
1156
+ end
1157
+
1158
+ def render_skills_index
1159
+ load_skills_master
1160
+ # Auto-open the first skill (drill-down convention) so the detail pane is
1161
+ # useful on landing; the empty state only shows with an empty catalog.
1162
+ @selected = @skills.first&.name
1163
+ @skill_content = @selected ? skill_source(@selected) : nil
1164
+ view("skills")
1165
+ end
1166
+
1167
+ # Raw content for the editor: prefer the store (real SKILL.md), otherwise
1168
+ # reconstruct from what the catalog parsed (editing a disk skill
1169
+ # creates an override in the store — Store wins).
1170
+ def skill_source(name, agent: nil)
1171
+ raw = insika[:skill_store]&.get(name, agent: agent)
1172
+ return raw if raw
1173
+
1174
+ skill = insika[:skill_catalog]&.find(name, agent: agent)
1175
+ return new_skill_template(name) unless skill
1176
+
1177
+ # The WHOLE frontmatter, not just name/description: this text seeds the
1178
+ # specialize action and any first edit of a disk skill, and an override that
1179
+ # silently drops `triggers:` turns the agent's deterministic activation off.
1180
+ fm = ["name: #{skill.name}", "description: #{skill.description}"]
1181
+ fm << "triggers: #{skill.triggers.join(', ')}" if Array(skill.triggers).any?
1182
+ fm << "companions: #{skill.companions.join(', ')}" if Array(skill.companions).any?
1183
+ "---\n#{fm.join("\n")}\n---\n\n#{skill.body}\n"
1184
+ end
1185
+
1186
+ def new_skill_template(name = "my-skill")
1187
+ "---\nname: #{name}\ndescription: one sentence about when to use this skill\n---\n\n" \
1188
+ "# #{name}\n\nFull instructions, loaded on demand by the `load_skill` tool.\n"
1189
+ end
1190
+
1191
+ # A skill is active for an agent if it has `skills` = nil (all) or the
1192
+ # list includes the name. Used to pre-check the matrix checkboxes.
1193
+ def skill_enabled_for?(profile, skill_name)
1194
+ profile.skills.nil? || Array(profile.skills).map(&:to_s).include?(skill_name.to_s)
1195
+ end
1196
+
1197
+ # Is this skill always-on for this agent? `skills_eager` is nil/false (none), true
1198
+ # (blanket) or a list — NOT allowlist semantics, so nil must read as false here.
1199
+ def skill_eager_for?(profile, skill_name)
1200
+ spec = profile.skills_eager
1201
+ return true if Insika::Coercion.truthy?(spec)
1202
+ return false if spec.nil? || spec == false
1203
+
1204
+ Array(spec).map(&:to_s).include?(skill_name.to_s)
1205
+ end
1206
+
1207
+ # The two "left intact" outcomes of :set_skill_agents: an agent whose allowlist (or
1208
+ # eager set) is "all" cannot have ONE name removed from it without materializing an
1209
+ # explicit list, which would be a destructive surprise. Say so instead of silently
1210
+ # doing nothing.
1211
+ def note_skipped(result)
1212
+ return unless result
1213
+
1214
+ parts = []
1215
+ skipped = Array(result[:skipped_all])
1216
+ eager = Array(result[:skipped_eager_all])
1217
+ parts << "#{skipped.size} agent(s) with 'all' skills were left intact" if skipped.any?
1218
+ parts << "#{eager.size} agent(s) with blanket eager were left intact" if eager.any?
1219
+ flash["notice"] = "#{flash['notice']} — #{parts.join(', ')}." if parts.any?
1220
+ end
1221
+
1222
+ # --- Tools matrix --------------------------------------------------------
1223
+
1224
+ def render_tools_matrix
1225
+ @tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
1226
+ # Names of the DATA-DEFINED tools (editable via the UI). The rest of the catalog are
1227
+ # code tools (allow/deny only). Used to mark and link the editor.
1228
+ @data_tool_names = insika[:tool_store] ? insika[:tool_store].names : []
1229
+ # Stored but NOT in the catalog = the overlay refused the definition and dropped it
1230
+ # (only a stderr warn otherwise). The pane still links its editor, so the panel is
1231
+ # where you see it and where you fix it. `insika doctor` reports the same set.
1232
+ @dropped_tool_names = @data_tool_names - @tools.map(&:name)
1233
+ @agents = insika[:profile_source].all.sort_by(&:id)
1234
+ # Drill-down: ?a= selects the agent whose allow/deny matrix fills the detail;
1235
+ # default to the first agent so the pane is useful on landing.
1236
+ sel = request.params["a"]
1237
+ @sel_agent = (sel && @agents.find { |a| a.id == sel }) || @agents.first
1238
+ view("tools")
1239
+ end
1240
+
1241
+ # nil = all; otherwise the list. Pre-checks the checkboxes per agent.
1242
+ def tool_allowed_for?(profile, tool_name)
1243
+ profile.tools_allow.nil? || Array(profile.tools_allow).map(&:to_s).include?(tool_name.to_s)
1244
+ end
1245
+
1246
+ # A tool the agent explicitly denies. Deny ALWAYS wins over allow, so the matrix
1247
+ # renders these "locked" (disabled) — you can't grant a denied tool from here.
1248
+ def tool_denied_for?(profile, tool_name)
1249
+ Array(profile.tools_deny).map(&:to_s).include?(tool_name.to_s)
1250
+ end
1251
+
1252
+ # `checked/total on` for an agent's tool group (open allowlist -> total). Feeds
1253
+ # the initial server-rendered counter; the toggle-counter island keeps it live.
1254
+ def tools_on_count(profile, tools)
1255
+ total = tools.size
1256
+ return total if profile.tools_allow.nil?
1257
+
1258
+ allow = Array(profile.tools_allow).map(&:to_s)
1259
+ tools.count { |t| allow.include?(t.name.to_s) && !tool_denied_for?(profile, t.name) }
1260
+ end
1261
+
1262
+ # Data-defined tool authoring -------------------------------
1263
+
1264
+ def render_tool_edit(name:, tool:)
1265
+ @tool_name = name
1266
+ @form = tool_form(tool)
1267
+ @versions = tool && insika[:tool_store] ? insika[:tool_store].versions(name) : []
1268
+ view("tool_edit")
1269
+ end
1270
+
1271
+ # Definition (masked) -> Hash of text fields ready for the form. tool=nil
1272
+ # (new) -> defaults. Mirrors env_lines/param_lines for headers/query/params.
1273
+ def tool_form(tool)
1274
+ t = tool || {}
1275
+ req = t["request"] || {}
1276
+ resp = t["response"] || {}
1277
+ {
1278
+ name: t["name"].to_s, description: t["description"].to_s,
1279
+ method: req["method"] || "GET", url: req["url"].to_s,
1280
+ parameters: params_text(t["parameters"]),
1281
+ query: env_lines(req["query"]), headers: env_lines(req["headers"]),
1282
+ secret_headers: Array(t["secret_headers"]).join(", "),
1283
+ body: req["body"].to_s,
1284
+ extract: resp["extract"] || "body_raw", path: resp["path"].to_s,
1285
+ timeout: t["timeout"]
1286
+ }
1287
+ end
1288
+
1289
+ # tool_patch/parse_parameters moved to Studio::Forms.
1290
+
1291
+ # Inverse of parse_parameters: the stored params -> text for the textarea. A schema
1292
+ # the flat sugar can express round-trips as pipe lines (the friendly form); anything
1293
+ # NESTED renders as JSON Schema — the same text the form parses back, so opening and
1294
+ # saving a nested tool is a no-op instead of a silent flattening.
1295
+ def params_text(params)
1296
+ return param_lines(params) if flat_sugar?(params)
1297
+
1298
+ JSON.pretty_generate(params)
1299
+ end
1300
+
1301
+ def param_lines(params)
1302
+ flat_params(params).map do |p|
1303
+ req = p["required"] == false ? "optional" : "required"
1304
+ "#{p['name']} | #{p['type']} | #{req} | #{p['description']}"
1305
+ end.join("\n")
1306
+ end
1307
+
1308
+ # JSON Schema (Hash) OR flat array -> top-level [{name,type,required,description}].
1309
+ # An array property renders with its item type (`array:string`) — the spelling the
1310
+ # sugar accepts back. A legacy record with a bare `array` renders as `array:string`
1311
+ # too: that IS what it meant, now written down (see ToolDefinition.flat_property).
1312
+ def flat_params(params)
1313
+ if params.is_a?(Hash)
1314
+ props = params["properties"] || {}
1315
+ required = Array(params["required"]).map(&:to_s)
1316
+ props.map do |name, schema|
1317
+ schema ||= {}
1318
+ { "name" => name.to_s, "type" => flat_type(schema),
1319
+ "required" => required.include?(name.to_s), "description" => schema["description"].to_s }
1320
+ end
1321
+ else
1322
+ Array(params).map { |p| p.is_a?(Hash) ? p.merge("type" => flat_type_from_legacy(p)) : p }
1323
+ end
1324
+ end
1325
+
1326
+ # Can the flat textarea express this schema without losing anything? Only if every
1327
+ # top-level property is a scalar (or a list of scalars) and carries no keyword the
1328
+ # sugar cannot write back (nested properties, enum, minItems…).
1329
+ def flat_sugar?(params)
1330
+ return true unless params.is_a?(Hash)
1331
+ return false unless (params.keys - %w[type properties required]).empty?
1332
+
1333
+ (params["properties"] || {}).all? { |_, schema| flat_sugar_property?(schema) }
1334
+ end
1335
+
1336
+ def flat_sugar_property?(schema)
1337
+ return false unless schema.is_a?(Hash)
1338
+ return false unless (schema.keys - %w[type description items]).empty?
1339
+
1340
+ type = schema["type"].to_s
1341
+ return Insika::ToolDefinition::PARAM_TYPES.include?(type) unless type == "array"
1342
+
1343
+ items = schema["items"]
1344
+ items.is_a?(Hash) && items.keys == ["type"] &&
1345
+ Insika::ToolDefinition::PARAM_TYPES.include?(items["type"].to_s)
1346
+ end
1347
+
1348
+ def flat_type(schema)
1349
+ type = (schema["type"] || "string").to_s
1350
+ return type unless type == "array"
1351
+
1352
+ "array:#{schema.dig('items', 'type') || 'string'}"
1353
+ end
1354
+
1355
+ def flat_type_from_legacy(param)
1356
+ type = (param["type"] || "string").to_s
1357
+ type == "array" ? "array:string" : type
1358
+ end
1359
+
1360
+ def tool_def_path(name) = "/studio/tools/def/#{Rack::Utils.escape(name.to_s)}"
1361
+
1362
+ # --- Overview / home ------------------------------------------------------
1363
+
1364
+ # At-a-glance dashboard: counts + activity + recent conversations. All from
1365
+ # data the Studio already reads (one scan of the session store); no new
1366
+ # metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
1367
+ def render_home
1368
+ ps = insika[:profile_source]
1369
+ sessions = all_sessions
1370
+ @counts = {
1371
+ "conversations" => sessions.size,
1372
+ "messages" => sessions.sum { |s| Array(s.messages).size },
1373
+ "agents" => ps ? ps.all.size : 0,
1374
+ "skills" => insika[:skill_catalog] ? insika[:skill_catalog].all.size : 0,
1375
+ "tools" => insika[:tool_catalog] ? insika[:tool_catalog].all.size : 0,
1376
+ "providers" => insika[:llm_provider_store] ? insika[:llm_provider_store].all.size : 0,
1377
+ "MCP servers" => insika[:mcp_store] ? insika[:mcp_store].all.size : 0
1378
+ }
1379
+ now = Time.now
1380
+ cutoff = now - (5 * 60)
1381
+ @active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
1382
+ @recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
1383
+ @activity = activity_by_day(sessions, days: 14, now: now)
1384
+ @persistence = insika.dig(:config, :persistence)
1385
+ view("home")
1386
+ end
1387
+
1388
+ def all_sessions
1389
+ store = insika[:session_store]
1390
+ return [] unless store
1391
+
1392
+ store.each_id.filter_map { |sid| store.find(sid) }
1393
+ end
1394
+
1395
+ def parse_time(str)
1396
+ s = str.to_s
1397
+ return nil if s.empty?
1398
+
1399
+ Time.parse(s)
1400
+ rescue ArgumentError
1401
+ nil
1402
+ end
1403
+
1404
+ # [[Date, count], …] — one bucket per day over the window, most-recent last.
1405
+ def activity_by_day(sessions, days:, now:)
1406
+ today = now.to_date
1407
+ buckets = Hash.new(0)
1408
+ sessions.each do |s|
1409
+ t = parse_time(s.updated_at) or next
1410
+ buckets[t.to_date] += 1
1411
+ end
1412
+ (0...days).to_a.reverse.map { |i| d = today - i; [d, buckets[d]] }
1413
+ end
1414
+
1415
+ # --- History -------------------------------------------------------------
1416
+
1417
+ # Recent conversations (all agents — the Session doesn't stamp the agent that
1418
+ # produced it). Most recent first, capped.
1419
+ def recent_sessions(limit: 8)
1420
+ store = insika[:session_store]
1421
+ return [] unless store
1422
+
1423
+ store.each_id.filter_map { |sid| store.find(sid) }
1424
+ .sort_by { |s| s.updated_at.to_s }.reverse.first(limit)
1425
+ end
1426
+
1427
+ # Compact relative age ("just now", "9min", "3h", "2d") for a timestamp string.
1428
+ def time_ago(str)
1429
+ t = parse_time(str) or return str.to_s
1430
+ secs = (Time.now - t).to_i
1431
+ return "just now" if secs < 60
1432
+
1433
+ mins = secs / 60
1434
+ return "#{mins}min" if mins < 60
1435
+
1436
+ hrs = mins / 60
1437
+ return "#{hrs}h" if hrs < 24
1438
+
1439
+ "#{hrs / 24}d"
1440
+ end
1441
+
1442
+ def session_preview(session)
1443
+ last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
1444
+ last && last["content"].to_s
1445
+ end
1446
+
1447
+ # --- Settings + LLM providers ----------------------------------
1448
+
1449
+ SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
1450
+ def render_settings
1451
+ store = insika[:settings_store]
1452
+ @settings = store ? store.get : Insika::SettingsStore::DEFAULTS
1453
+ @providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
1454
+ @section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
1455
+ view("settings")
1456
+ end
1457
+
1458
+ # Settings patch from the form. streaming is a bool (checkbox); the timeouts
1459
+ # are integers. Only what came in the form enters the patch (the rest and
1460
+ # the defaults are preserved in the store).
1461
+ # settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms.
1462
+
1463
+ # --- MCP -------------------------------------------------------
1464
+
1465
+ def render_mcp
1466
+ @instances = insika[:mcp_store] ? insika[:mcp_store].all : []
1467
+ view("mcp")
1468
+ end
1469
+
1470
+ # mcp_patch moved to Studio::Forms.
1471
+
1472
+ # --- System-files ----------------------------------------------
1473
+
1474
+ def render_system_files
1475
+ store = insika[:system_file_store]
1476
+ names = store ? store.list : []
1477
+ @system_files = names.map do |name|
1478
+ { name: name, content: store.read(name).to_s, versions: store.versions(name) }
1479
+ end
1480
+ view("system_files")
1481
+ end
1482
+
1483
+ # --- Chats -----------------------------------------------------
1484
+
1485
+ def render_chats
1486
+ @sessions = recent_sessions(limit: 100)
1487
+ view("chats")
1488
+ end
1489
+
1490
+ # Tasks & Approvals --------------------------------
1491
+
1492
+ # Task list, most-recently-updated first. Empty-state if no store was injected.
1493
+ def render_tasks
1494
+ store = insika[:task_store]
1495
+ @tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
1496
+ @tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
1497
+ view("tasks")
1498
+ end
1499
+
1500
+ # Task detail: @task is set by the route. Adds the open approvals for this task
1501
+ # and its latest checkpoint (both degrade to empty when the store is absent).
1502
+ def render_task_detail(id)
1503
+ @pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id) : []
1504
+ @checkpoint = insika[:checkpoint_store]&.latest(id)
1505
+ view("task")
1506
+ end
1507
+
1508
+ # Approvals inbox: every :pending action across tasks, each paired with its
1509
+ # task (for the status pill + a link into the task detail).
1510
+ def render_approvals
1511
+ pstore = insika[:pending_action_store]
1512
+ tstore = insika[:task_store]
1513
+ @approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
1514
+ view("approvals")
1515
+ end
1516
+
1517
+ # Evals -------------------------------------
1518
+
1519
+ # The stored cases, grouped by agent, plus the one being edited (?id=). Cases whose
1520
+ # stored mapping no longer validates are listed separately: a broken case must be
1521
+ # visible, because a run silently skips it.
1522
+ def render_evals
1523
+ store = insika[:golden_store]
1524
+ @cases = store ? store.all : []
1525
+ @invalid = store ? store.invalid : []
1526
+ @by_agent = @cases.group_by(&:agent).sort.to_h
1527
+ wanted = presence(request.params["id"])
1528
+ @case = wanted && @cases.find { |g| g.id == wanted }
1529
+ @case_yaml = @case ? golden_yaml(@case) : nil
1530
+ view("evals")
1531
+ end
1532
+
1533
+ # The case as the YAML an operator edits — the same shape `evals/golden/**` holds,
1534
+ # so there is one format to learn and a pull request can review what was authored.
1535
+ def golden_yaml(golden)
1536
+ h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
1537
+ h["requires"] = golden.requires unless golden.requires.empty? # dropping it would un-skip the case
1538
+ h["reference"] = golden.reference unless golden.reference.empty? # …and this would un-compare it
1539
+ YAML.dump(h.merge("expect" => golden.expect))
1540
+ end
1541
+
1542
+ # Refinement -----------------------------
1543
+
1544
+ # The agent's latest failure report + its run history. Empty-state when no store
1545
+ # was injected or the agent has never been run — the page is the invitation to
1546
+ # run it, so there is nothing to hide behind a nil.
1547
+ def render_refinement
1548
+ @agents = insika[:profile_source].ids.sort
1549
+ @agent = presence(request.params["agent"]) || @agents.first
1550
+ store = insika[:refinement_store]
1551
+ @runs = @agent && store ? store.for_agent(@agent, limit: 10) : []
1552
+ @run = @runs.find(&:terminal?)
1553
+ # The one run that owes this agent a human answer. At most one is possible:
1554
+ # gating requires a `completed` run and there is one lifecycle per record.
1555
+ @proposal = @runs.find(&:awaiting_approval?)
1556
+ view("refinement")
1557
+ end
1558
+
1559
+ # A run's window in words. An EMPTY window means "the collector's own default" —
1560
+ # resolve it for the operator instead of rendering a blank.
1561
+ def refinement_window(window)
1562
+ return "since #{window['since']}" if window["since"]
1563
+
1564
+ "last #{window['last_sessions'] || Insika::Refinement::EvidenceCollector::DEFAULT_WINDOW} conversation(s)"
1565
+ end
1566
+
1567
+ # An operator control (pause/resume/cancel/approve): audits the ATTEMPT to the
1568
+ # shared EventStream BEFORE dispatching — accountability survives a Command
1569
+ # failure (parity with server/admin's `act`) — then dispatches via the bus with
1570
+ # a success/error flash. The audit carries only metadata, never message/args —
1571
+ # an event reaches every subscriber of the stream, so it stays free of content.
1572
+ def control_action(type, payload, ok:)
1573
+ emit_operator_action(type, payload)
1574
+ with_flash(ok) { dispatch(type, payload) }
1575
+ end
1576
+
1577
+ def emit_operator_action(type, payload)
1578
+ stream = insika[:event_stream]
1579
+ return unless stream
1580
+
1581
+ stream.emit(Insika::Event.new(
1582
+ type: :operator_action,
1583
+ data: { action: type.to_s,
1584
+ target: payload.slice(:task_id, :pending_id, :decision, :agent),
1585
+ operator: operator_label },
1586
+ meta: { task_id: payload[:task_id], at: Time.now.utc.iso8601 }
1587
+ ))
1588
+ end
1589
+
1590
+ # Operator identity for the audit/approval (single admin token → the Studio
1591
+ # is the operator). Mirrors server/admin's operator_of default.
1592
+ def operator_label = "studio"
1593
+
1594
+ # Semantic status class for the .pill CSS (completed=ok, running=run,
1595
+ # waiting/queued/paused=warn, failed/cancelled=err). Parity with admin's
1596
+ # status_pill mapping; used by the tasks/approvals views.
1597
+ def status_class(status)
1598
+ case status.to_s
1599
+ when "completed" then "ok"
1600
+ when "running" then "run"
1601
+ when "waiting", "queued", "paused" then "warn"
1602
+ when "failed", "cancelled" then "err"
1603
+ else "info"
1604
+ end
1605
+ end
1606
+
1607
+ def task_path(id) = "/studio/tasks/#{Rack::Utils.escape(id.to_s)}"
1608
+
1609
+ # parse_kv_lines moved to Studio::Forms.
1610
+
1611
+ # CSV/whitespace -> [String], blanks dropped.
1612
+ def split_list(str)
1613
+ str.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?)
1614
+ end
1615
+
1616
+ # --- Playground ----------------------------------------------------------
1617
+
1618
+ # Dispatches the send_message through the SAME bus as the API (no direct writes to a
1619
+ # store). tenant = agent → the turn's memory is the agent's (parity with the
1620
+ # memory page). The UI observes the turn via SSE.
1621
+ def dispatch_send_message(agent:, session_id:, message:)
1622
+ dispatch(:send_message, { agent: agent, session_id: session_id, message: message }, tenant: agent)
1623
+ end
1624
+
1625
+ # Creates a new session via the bus (create_session generates the id) and returns
1626
+ # the id. `model`/`provider` (optional) become the per-chat pin: CreateSession
1627
+ # stashes them in the reserved `vars["__llm__"]` slot the ModelResolver reads as
1628
+ # the highest-precedence layer (Chat > Agent > platform default). Only non-blank
1629
+ # values are sent, so an empty override leaves the session unpinned.
1630
+ def create_session(model: nil, provider: nil, thinking: nil)
1631
+ payload = { vars: { "canal" => "studio" } }
1632
+ payload[:model] = model if model
1633
+ payload[:provider] = provider if provider
1634
+ payload[:thinking] = thinking if thinking
1635
+ dispatch(:create_session, payload).id
1636
+ end
1637
+
1638
+ def playground_path(agent, session_id)
1639
+ query = "agent=#{Rack::Utils.escape(agent)}"
1640
+ query += "&session_id=#{Rack::Utils.escape(session_id)}" if session_id
1641
+ "/studio/playground?#{query}"
1642
+ end
1643
+
1644
+ def agent_path(id, anchor = nil)
1645
+ base = "/studio/agents/#{Rack::Utils.escape(id)}"
1646
+ anchor ? "#{base}##{anchor}" : base
1647
+ end
1648
+
1649
+ # Serves a versioned asset from dist. `File.basename` kills path traversal; only
1650
+ # files that exist in the dist dir are served.
1651
+ def serve_asset(name)
1652
+ base = File.basename(name)
1653
+ path = File.join(ASSETS_DIR, base)
1654
+ unless File.file?(path) && File.fnmatch(File.join(ASSETS_DIR, "*"), path)
1655
+ response.status = 404
1656
+ return "not found"
1657
+ end
1658
+
1659
+ response["content-type"] = CONTENT_TYPES.fetch(File.extname(base), "application/octet-stream")
1660
+ # no-cache (revalidate every load) + the ?v=mtime bust in asset_path: a
1661
+ # rebuilt CSS/JS is NEVER served stale, even mid-session across restarts.
1662
+ # Assets are tiny and same-origin, so the revalidation cost is negligible —
1663
+ # correctness over caching for an actively-edited admin UI.
1664
+ response["cache-control"] = "no-cache"
1665
+ File.read(path)
1666
+ end
1667
+
1668
+ # Cache-busting URL for a dist asset. Dist files are served under a STABLE
1669
+ # name with max-age=300, so a rebuilt CSS/JS stays masked by the browser
1670
+ # cache for 5 min — even across a server restart (the "restarted and it's
1671
+ # still broken" trap). Appending the file mtime as ?v= changes the URL whenever the
1672
+ # asset changes, so the browser always refetches the fresh build. The query
1673
+ # is ignored by serve_asset (it matches on the path segment).
1674
+ def asset_path(file)
1675
+ base = File.basename(file)
1676
+ path = File.join(ASSETS_DIR, base)
1677
+ v = File.file?(path) ? File.mtime(path).to_i : 0
1678
+ "/studio/assets/dist/#{base}?v=#{v}"
1679
+ end
1680
+
1681
+ def presence(str) = Insika::Coercion.presence(str)
1682
+
1683
+ # Masked-secret sentinel (to pre-fill credential fields in the
1684
+ # forms: resubmitting without touching preserves the real secret in the store).
1685
+ def secret_sentinel = Insika::SecretMasking::SENTINEL
1686
+
1687
+ # An MCP instance's env (already MASKED) -> "KEY=value" text per line,
1688
+ # for the textarea. Sorts by key (stable across renders).
1689
+ def env_lines(env)
1690
+ (env || {}).sort.map { |k, v| "#{k}=#{v}" }.join("\n")
1691
+ end
1692
+ end
1693
+ end