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