insika 0.9.0 → 1.0.1

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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -92,7 +92,7 @@ module Studio
92
92
  tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
93
93
  memory_audit_store: nil,
94
94
  settings_store: nil, llm_provider_store: nil, mcp_store: nil,
95
- system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
95
+ system_file_store: nil, tool_trace_store: nil, llm_trace_store: nil, model_metrics_store: nil, context_trace_store: nil,
96
96
  cache_series_store: nil,
97
97
  task_store: nil, checkpoint_store: nil, pending_action_store: nil,
98
98
  refinement_store: nil, golden_store: nil, session_secret: nil,
@@ -122,7 +122,7 @@ module Studio
122
122
  mcp_store: mcp_store, system_file_store: system_file_store,
123
123
  # per-session tool-call trace (debug): args + result + status per
124
124
  # turn, rendered in the session viewer.
125
- tool_trace_store: tool_trace_store,
125
+ tool_trace_store: tool_trace_store, llm_trace_store: llm_trace_store, model_metrics_store: model_metrics_store,
126
126
  # per-session context breakdown: tokens by category +
127
127
  # budget per turn, on the same viewer. Counts only, no content.
128
128
  context_trace_store: context_trace_store,
@@ -216,6 +216,14 @@ module Studio
216
216
 
217
217
  # Versioned assets: public (the UI loads the bundle BEFORE login).
218
218
  r.on "assets", "dist" do
219
+ # The embedded webfonts are the one sub-directory under dist; the "fonts"
220
+ # segment is matched literally, so serve_asset still only ever joins a
221
+ # basename onto a path this app chose.
222
+ r.on "fonts" do
223
+ r.get String do |name|
224
+ serve_asset(name, "fonts")
225
+ end
226
+ end
219
227
  r.get String do |name|
220
228
  serve_asset(name)
221
229
  end
@@ -224,7 +232,8 @@ module Studio
224
232
  # Login: the only path without a session. GET shows the form; POST validates the
225
233
  # token in constant time and, if ok, marks the session and redirects.
226
234
  r.is "login" do
227
- r.get { view("login") }
235
+ # Already signed in: the form would render inside the shell — go home.
236
+ r.get { authenticated? ? r.redirect("/studio/home") : view("login") }
228
237
  r.post do
229
238
  check_csrf!
230
239
  if authenticate(r.params["token"])
@@ -269,7 +278,8 @@ module Studio
269
278
  r.get "events" do
270
279
  next_404 unless insika[:event_stream] # no stream wired: the feature is not there
271
280
  subscription = insika[:event_stream].subscribe(
272
- task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"])
281
+ task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"]),
282
+ replay: true # a viewer arriving mid-turn sees what the turn already did
273
283
  )
274
284
  r.halt([200,
275
285
  { "content-type" => "text/event-stream", "cache-control" => "no-cache",
@@ -277,24 +287,30 @@ module Studio
277
287
  Insika::Server::SSEBody.new(subscription: subscription)])
278
288
  end
279
289
 
280
- # `/studio` and `/studio/` → overview home.
290
+ # `/studio/` → overview home. A bare `/studio` reaches here with an empty
291
+ # path (Rack::URLMap strips the mount), which r.root does not match.
292
+ r.redirect("/studio/home") if r.remaining_path.empty?
281
293
  r.root { r.redirect("/studio/home") }
282
294
 
283
295
  # --- Overview: at-a-glance dashboard ---------------------
296
+ r.on "inbox" do
297
+ r.is { r.get { render_inbox } }
298
+ end
299
+
284
300
  r.on "home" do
285
301
  r.is { r.get { render_home } }
286
302
  end
287
303
 
304
+ r.on "models" do
305
+ r.is { r.get { render_models } }
306
+ end
307
+
288
308
  # --- Agents: list + detail/authoring ---------------------
289
309
  r.on "agents" do
290
310
  # /studio/agents — agents grid (reads the ProfileSource).
291
311
  r.is do
292
312
  r.get do
293
313
  @agents = insika[:profile_source].all.sort_by(&:id)
294
- # WS7 scorecard: the LAST outcome per agent, computed once for the
295
- # whole grid (a store scan per card would be n scans). Series live
296
- # on the agent detail — one agent's periods, not n charts here.
297
- @latest_outcomes = insika[:outcome_store]&.latest_per_agent
298
314
  # "New from template" gallery — cheap (frontmatter
299
315
  # parse only, no evaluation) so it's safe on every render.
300
316
  @templates = Insika::Templates.all
@@ -307,10 +323,13 @@ module Studio
307
323
  check_csrf!
308
324
  id = presence(r.params["id"])
309
325
  result = with_flash("Agent '#{id}' created.") do
326
+ model, provider = picked_model(r)
310
327
  dispatch(:create_agent, {
311
- id: id, model: presence(r.params["model"]),
312
- provider: presence(r.params["provider"]),
313
- memory: r.params["memory"] == "1"
328
+ id: id, model: model, provider: provider,
329
+ memory: r.params["memory"] == "1",
330
+ # Starts with nothing allowed: kits and the pickers decide
331
+ # what it gets (an "all" start made every kit a no-op).
332
+ tools_allow: [], skills: []
314
333
  })
315
334
  end
316
335
  r.redirect(result ? agent_path(id) : "/studio/agents")
@@ -344,6 +363,10 @@ module Studio
344
363
  # Config/model → :update_agent (patch merge).
345
364
  r.post "config" do
346
365
  check_csrf!
366
+ # A section the agent stops inheriting starts from the platform's
367
+ # value, so the keys its form does not show (knowledge index,
368
+ # guardrails corpora) carry over.
369
+ @agent = Insika::AgentDefaults.apply_safely(@agent, platform_agent_defaults)
347
370
  with_flash("Configuration saved.") do
348
371
  dispatch(:update_agent, config_patch(r))
349
372
  end
@@ -451,6 +474,30 @@ module Studio
451
474
  end
452
475
  end
453
476
 
477
+ # POST /studio/agents/:id/tools — the agent's Tools tab. "all" = nil;
478
+ # otherwise the checked subset. `deny` is preserved.
479
+ r.post "tools" do
480
+ check_csrf!
481
+ profile = insika[:profile_source].fetch(id)
482
+ next_404 unless profile
483
+ all = r.params["all_tools"] == "1"
484
+ allow = all ? nil : Array(r.params["tools"]).map(&:to_s)
485
+ # "all tools" means every tool: a leftover group list would narrow it to
486
+ # those groups. Otherwise only the server switches this tab rendered
487
+ # (managed_groups) change; other groups (a pack's) stay.
488
+ groups = if all then nil
489
+ else
490
+ managed = Array(r.params["managed_groups"]).map(&:to_s)
491
+ kept = Array(profile.tools_allow_groups).map(&:to_s) - managed
492
+ picked = kept | Array(r.params["tool_groups"]).map(&:to_s)
493
+ picked.empty? && profile.tools_allow_groups.nil? ? nil : picked
494
+ end
495
+ with_flash("Tools updated.") do
496
+ dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny), allow_groups: groups })
497
+ end
498
+ r.redirect(agent_path(id, nil, "tab=tools"))
499
+ end
500
+
454
501
  r.on "skills" do
455
502
  # POST /studio/agents/:id/skills → :update_agent with the `skills`
456
503
  # allowlist. "all" = nil; otherwise the checked subset (possibly []).
@@ -477,6 +524,7 @@ module Studio
477
524
  @selected = name
478
525
  @scope_agent = id
479
526
  @skill_content = skill_source(name, agent: id)
527
+ @skill_versions = skill_versions(name, agent: id)
480
528
  view("skills")
481
529
  end
482
530
  # Saving the specialization: same command, `agent:` set.
@@ -489,6 +537,12 @@ module Studio
489
537
  end
490
538
  end
491
539
 
540
+ r.post "restore" do
541
+ check_csrf!
542
+ with_flash("Version restored.") { restore_skill_version(name, r.params["version"], agent: id) }
543
+ r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
544
+ end
545
+
492
546
  # Un-specialize: the shared skill stays, and the agent falls back to it.
493
547
  r.post "delete" do
494
548
  check_csrf!
@@ -563,9 +617,15 @@ module Studio
563
617
  load_skills_master
564
618
  @selected = name
565
619
  @skill_content = skill_source(name)
620
+ @skill_versions = skill_versions(name)
566
621
  view("skills")
567
622
  end
568
623
  end
624
+ r.post "restore" do
625
+ check_csrf!
626
+ with_flash("Version restored.") { restore_skill_version(name, r.params["version"]) }
627
+ r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
628
+ end
569
629
  # Enables/disables the skill on N agents at once, and marks it always-on for
570
630
  # the ones that checked `eager`. Both are per-agent decisions about the same
571
631
  # skill, so they are the same form and the same dispatch.
@@ -586,6 +646,11 @@ module Studio
586
646
  r.post "specialize" do
587
647
  check_csrf!
588
648
  agent_id = presence(r.params["agent_id"])
649
+ # the picker is free text (type-to-search), so the id is checked here
650
+ unless agent_id && insika[:profile_source].fetch(agent_id)
651
+ flash["error"] = "Agent '#{agent_id}' not found."
652
+ r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
653
+ end
589
654
  with_flash("Specialized for #{agent_id}.") do
590
655
  dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
591
656
  end
@@ -594,6 +659,69 @@ module Studio
594
659
  end
595
660
  end
596
661
 
662
+ # --- Kits: named bundles of skills + tools agents subscribe to ----------
663
+ r.on "kits" do
664
+ r.is do
665
+ r.get do
666
+ load_kits
667
+ @selected = nil
668
+ view("kits")
669
+ end
670
+ r.post do
671
+ check_csrf!
672
+ name = presence(r.params["name"]).to_s
673
+ payload = { name: name, description: r.params["description"].to_s,
674
+ skills: Array(r.params["skills"]).map(&:to_s),
675
+ tools: Array(r.params["tools"]).map(&:to_s),
676
+ tool_groups: Array(r.params["tool_groups"]).map(&:to_s) }
677
+ result = with_flash("Kit '#{name}' saved.") { dispatch(:write_kit, payload) }
678
+ r.redirect(result ? "/studio/kits/#{Rack::Utils.escape(name)}" : "/studio/kits")
679
+ end
680
+ end
681
+
682
+ r.get "new" do
683
+ load_kits
684
+ @selected = ""
685
+ @kit = {}
686
+ view("kits")
687
+ end
688
+
689
+ r.on String do |name|
690
+ name = utf8(name)
691
+ r.is do
692
+ r.get do
693
+ load_kits
694
+ @kit = @kits[name]
695
+ next_404 unless @kit
696
+ @selected = name
697
+ view("kits")
698
+ end
699
+ end
700
+ # Membership lives on the AGENT (AgentProfile#kits). Only agents whose
701
+ # membership of THIS kit changed are written, and their other kits stay.
702
+ r.post "agents" do
703
+ check_csrf!
704
+ chosen = Array(r.params["agent_ids"]).map(&:to_s)
705
+ with_flash("Kit '#{name}' agents updated.") do
706
+ insika[:profile_source].all.each do |a|
707
+ has = Array(a.kits).include?(name)
708
+ want = chosen.include?(a.id)
709
+ next if has == want
710
+
711
+ kits = want ? Array(a.kits) | [name] : Array(a.kits) - [name]
712
+ dispatch(:update_agent, { id: a.id, kits: kits })
713
+ end
714
+ end
715
+ r.redirect("/studio/kits/#{Rack::Utils.escape(name)}")
716
+ end
717
+ r.post "delete" do
718
+ check_csrf!
719
+ with_flash("Kit '#{name}' removed.") { dispatch(:delete_kit, { name: name }) }
720
+ r.redirect("/studio/kits")
721
+ end
722
+ end
723
+ end
724
+
597
725
  # --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
598
726
  r.on "tools" do
599
727
  # Data-defined tool authoring. Under /tools/def/* — BEFORE the
@@ -650,20 +778,16 @@ module Studio
650
778
  end
651
779
  end
652
780
 
653
- r.is { r.get { render_tools_matrix } }
781
+ r.is { r.get { render_tools_page } }
654
782
 
655
- # POST /studio/tools/:id — writes an agent's tools allowlist.
656
- # "all" = nil; otherwise the checked subset. `deny` is preserved.
657
- r.post String do |id|
658
- check_csrf!
659
- id = utf8(id)
660
- profile = insika[:profile_source].fetch(id)
661
- next_404 unless profile
662
- allow = r.params["all_tools"] == "1" ? nil : Array(r.params["tools"]).map(&:to_s)
663
- with_flash("Agent '#{id}' tools updated.") do
664
- dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny) })
665
- end
666
- r.redirect("/studio/tools?a=#{Rack::Utils.escape(id)}")
783
+ # GET /studio/tools/info/:name — read-only card for a code or MCP tool.
784
+ r.get "info", String do |name|
785
+ name = utf8(name)
786
+ load_tool_catalog
787
+ @info_tool = @tools.find { |t| t.name == name }
788
+ next_404 unless @info_tool
789
+ @info_label = @tool_groups.find { |_, tools| tools.include?(@info_tool) }&.first
790
+ render_tools_page(detail: :info, selected: name)
667
791
  end
668
792
  end
669
793
 
@@ -711,6 +835,26 @@ module Studio
711
835
  r.redirect("/studio/settings?s=edge")
712
836
  end
713
837
 
838
+ # Burst policy: the platform QueuePolicy layer. Its own form for the same
839
+ # reason as edge — and so "apply to every agent" is one save instead of
840
+ # one edit per agent (each agent still overrides in its config).
841
+ # Platform agent defaults: what an agent with no value of its own runs with.
842
+ r.post "agent-defaults" do
843
+ check_csrf!
844
+ with_flash("Agent defaults saved.") do
845
+ dispatch(:update_settings, { patch: { "agent_defaults" => agent_defaults_patch(r) } })
846
+ end
847
+ r.redirect("/studio/settings?s=agents")
848
+ end
849
+
850
+ r.post "queue" do
851
+ check_csrf!
852
+ with_flash("Burst policy saved.") do
853
+ dispatch(:update_settings, { patch: queue_patch(r) })
854
+ end
855
+ r.redirect("/studio/settings?s=burst")
856
+ end
857
+
714
858
  # Evals: the judge PANEL and how it agrees. Its own
715
859
  # form, like models and edge — a save here must not clobber those.
716
860
  r.post "evals" do
@@ -855,6 +999,13 @@ module Studio
855
999
  end
856
1000
  end
857
1001
 
1002
+ # --- Live floor: the latest conversations as cards ------------
1003
+ # Read-only. live_floor_controller keeps the cards and the event
1004
+ # ticker moving over /studio/events; the server only renders the baseline.
1005
+ r.on "live" do
1006
+ r.is { r.get { render_live } }
1007
+ end
1008
+
858
1009
  # --- Chats: conversations index ------------------------------
859
1010
  # Read-only: lists sessions and links to the existing viewer (/sessions/:id).
860
1011
  r.on "chats" do
@@ -868,18 +1019,36 @@ module Studio
868
1019
  # renders the two right zones alone.
869
1020
  r.on "sessions" do
870
1021
  r.on String do |sid|
871
- sid = utf8(sid)
1022
+ # Roda captures the segment URL-encoded; ids may hold a colon
1023
+ # (tenant:session), which every link escapes as %3A.
1024
+ sid = utf8(Rack::Utils.unescape_path(sid))
1025
+ # POST /studio/sessions/:id/guardrail — a person's verdict on one
1026
+ # guardrail flag (false positive / resolved / undo).
1027
+ r.post "guardrail" do
1028
+ check_csrf!
1029
+ status = presence(r.params["status"])
1030
+ with_flash(status ? "Guardrail flag marked #{status == "reported" ? "as a false positive" : status}." : "Guardrail report undone.") do
1031
+ dispatch(:review_guardrail_flag, { session_id: sid, message: r.params["message"],
1032
+ flag: r.params["flag"], status: status })
1033
+ end
1034
+ r.redirect(presence(r.params["back"])&.start_with?("/studio/") ? r.params["back"] : "/studio/sessions/#{Rack::Utils.escape_path(sid)}")
1035
+ end
1036
+
872
1037
  r.get do
873
1038
  @session = insika[:session_store]&.find(sid)
874
1039
  next_404 unless @session
875
1040
 
876
1041
  # master column: recent conversations, current one active
877
- @sessions = recent_sessions(limit: 60)
878
- # Session's tool-call trace (debug): grouped by turn in the view.
1042
+ setup_chat_list(r.params)
1043
+ # Session's tool-call trace (debug), one group per turn of a task:
1044
+ # `turn` restarts at 1 every task (entries without a task_id, written
1045
+ # before it, group by consecutive turn).
879
1046
  @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
880
- .group_by { |t| t["turn"] }
1047
+ .chunk_while { |a, b| a["task_id"] == b["task_id"] && a["turn"] == b["turn"] }.to_a
881
1048
  # Context breakdown per turn: chronological entries.
882
1049
  @context_traces = insika[:context_trace_store]&.for_session(sid) || []
1050
+ # Per-reply "why did it say that?" panels, drawn under each reply.
1051
+ @why = why_panels(Array(@session.messages), @context_traces, @tool_traces.flatten)
883
1052
  # the stage history — the session's outcomes folded
884
1053
  # into the agent's declared stages (unmapped kinds render raw).
885
1054
  @stage_history = stage_history_for(sid)
@@ -907,7 +1076,7 @@ module Studio
907
1076
  r.on String do |raw_scope|
908
1077
  # Roda captures the segment URL-encoded; a customer cell contains
909
1078
  # colons (memory:acme:c-1), so decode before parsing.
910
- scope = Rack::Utils.unescape_path(utf8(raw_scope))
1079
+ scope = utf8(Rack::Utils.unescape_path(raw_scope))
911
1080
  cell = Insika::MemoryStore.parse_cell(scope)
912
1081
 
913
1082
  r.get { render_customer(scope, cell) }
@@ -922,7 +1091,7 @@ module Studio
922
1091
  expires_at: presence(r.params["expires_at"]), operator: "studio"
923
1092
  })
924
1093
  end
925
- r.redirect(customer_path(scope))
1094
+ r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
926
1095
  end
927
1096
 
928
1097
  r.post "forget-fact" do
@@ -933,7 +1102,7 @@ module Studio
933
1102
  key: presence(r.params["key"]), operator: "studio"
934
1103
  })
935
1104
  end
936
- r.redirect(customer_path(scope))
1105
+ r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
937
1106
  end
938
1107
 
939
1108
  # The LGPD access right: JSON download of the cell's content (D7 —
@@ -952,7 +1121,7 @@ module Studio
952
1121
  JSON.pretty_generate(result)
953
1122
  rescue Insika::ValidationError => e
954
1123
  flash["error"] = e.message
955
- r.redirect(customer_path(scope))
1124
+ r.redirect(customer_path(scope, agent: presence(r.params["agent"])))
956
1125
  end
957
1126
 
958
1127
  # The LGPD forget — purges the cell AND the customer's sessions.
@@ -963,7 +1132,8 @@ module Studio
963
1132
  tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
964
1133
  })
965
1134
  end
966
- r.redirect("/studio/customers")
1135
+ agent = presence(r.params["agent"])
1136
+ r.redirect(agent ? "/studio/customers?agent=#{Rack::Utils.escape(agent)}" : "/studio/customers")
967
1137
  end
968
1138
  end
969
1139
  end
@@ -1090,6 +1260,14 @@ end
1090
1260
  control_action(:resolve_refinement, payload, ok: ok)
1091
1261
  r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
1092
1262
  end
1263
+
1264
+ # GET /studio/refinement/:id — one run's findings/proposal/diff. Same
1265
+ # miller-column pattern as agent_detail/fact_detail: a Turbo-Frame
1266
+ # request gets the pane alone, a plain hit gets the shell.
1267
+ r.on String do |id|
1268
+ id = utf8(id)
1269
+ r.get { render_refinement_detail(id) }
1270
+ end
1093
1271
  end
1094
1272
 
1095
1273
  # --- Harvest: the gated skill loop ----------------------
@@ -1164,7 +1342,7 @@ end
1164
1342
  dispatch(:write_concept, { agent: agent, name: name, tenant: tenant,
1165
1343
  content: r.params["content"].to_s }.compact)
1166
1344
  end
1167
- r.redirect(knowledge_path(agent, name, tenant))
1345
+ r.redirect(knowledge_path(agent, name, tenant, status: presence(r.params["status"])))
1168
1346
  end
1169
1347
  end
1170
1348
 
@@ -1201,7 +1379,7 @@ end
1201
1379
  with_flash("Concept removed.") do
1202
1380
  dispatch(:delete_concept, { agent: agent, name: name, tenant: tenant }.compact)
1203
1381
  end
1204
- r.redirect("/studio/knowledge?agent=#{Rack::Utils.escape(agent.to_s)}")
1382
+ r.redirect(knowledge_path(agent, nil, tenant, status: presence(r.params["status"])))
1205
1383
  end
1206
1384
 
1207
1385
  r.post "restore" do
@@ -1212,7 +1390,7 @@ end
1212
1390
  dispatch(:restore_concept, { agent: agent, name: name, tenant: tenant,
1213
1391
  version: r.params["version"] }.compact)
1214
1392
  end
1215
- r.redirect(knowledge_path(agent, name, tenant))
1393
+ r.redirect(knowledge_path(agent, name, tenant, status: presence(r.params["status"])))
1216
1394
  end
1217
1395
  end
1218
1396
  end
@@ -1222,7 +1400,10 @@ end
1222
1400
  # hit the stores, mutations go through the bus); the ONLY mutation — the
1223
1401
  # approve/reject/dismiss answer — dispatches :resolve_proposal.
1224
1402
  r.on "facts" do
1225
- r.get { render_facts }
1403
+ r.is do
1404
+ r.get { render_facts }
1405
+ end
1406
+
1226
1407
  r.post "resolve" do
1227
1408
  check_csrf!
1228
1409
  decision = presence(r.params["decision"])
@@ -1234,7 +1415,17 @@ end
1234
1415
  operator: "studio" })
1235
1416
  end
1236
1417
  filter = presence(r.params["filter"])
1237
- r.redirect("/studio/facts#{filter ? "?store=#{Rack::Utils.escape(filter)}" : ""}")
1418
+ # lands the operator on the NEXT pending item (same filter scope), not
1419
+ # an empty list — resolving one fact should feed straight into the next.
1420
+ r.redirect(next_fact_path(filter))
1421
+ end
1422
+
1423
+ # GET /studio/facts/:id — one fact's evidence + decision (pending/stale)
1424
+ # or its resolution (recent). Same miller-column pattern as agent_detail:
1425
+ # a Turbo-Frame request gets the pane alone, a plain hit gets the shell.
1426
+ r.on String do |id|
1427
+ id = utf8(id)
1428
+ r.get { render_fact_detail(id) }
1238
1429
  end
1239
1430
  end
1240
1431
 
@@ -1354,7 +1545,10 @@ end
1354
1545
  # (@sent_message) until it lands in history — an optimistic JS echo can't
1355
1546
  # work here (POST→redirect wipes the DOM).
1356
1547
  @history = @session_id ? Array(insika[:session_store]&.find(@session_id)&.messages) : []
1357
- @sent_message = flash["sent_message"]
1548
+ @sent_message = flash["sent_message"] || running_message(@session_id)
1549
+ # The id a NEW conversation will get, minted here so the router can
1550
+ # send its first turn to the worker that will keep running it.
1551
+ @new_session_id = SecureRandom.uuid unless @session_id
1358
1552
  view("playground")
1359
1553
  end
1360
1554
  r.post do
@@ -1362,15 +1556,18 @@ end
1362
1556
  agent = presence(r.params["agent"]) || default_agent
1363
1557
  typed_session = presence(r.params["session_id"])
1364
1558
  message = r.params["message"].to_s
1365
- # Blank session = new conversation: created via Command (create_session
1366
- # generates the id — the Studio doesn't write to the store directly). A typed id
1367
- # continues an existing conversation (send_message requires it to exist).
1559
+ # Blank session = new conversation: created via Command with the id the
1560
+ # page minted (the router already sent this POST to that id's worker —
1561
+ # the Studio doesn't write to the store directly). A typed id continues
1562
+ # an existing conversation (send_message requires it to exist); so does
1563
+ # a re-submitted page whose minted id already exists.
1368
1564
  # The per-chat model pin is set at creation and rides the whole
1369
1565
  # conversation, so it only applies to a NEW session — an existing one keeps
1370
1566
  # whatever it was pinned to.
1567
+ minted = presence(r.params["new_session_id"])
1371
1568
  session_id = typed_session ||
1372
- create_session(model: presence(r.params["model"]),
1373
- provider: presence(r.params["provider"]),
1569
+ (minted if minted && insika[:session_store]&.find(minted)) ||
1570
+ create_session(id: minted, **pinned_model(r),
1374
1571
  thinking: presence(r.params["thinking"]))
1375
1572
  dispatch_send_message(agent: agent, session_id: session_id, message: message)
1376
1573
  # Optimistic echo of the just-sent message: survives the redirect
@@ -1394,53 +1591,162 @@ end
1394
1591
 
1395
1592
  def insika = self.class.insika
1396
1593
 
1397
- # Sidebar navigation, grouped by operator intent (build / runtime /
1398
- # operate). Each item: [label, href, icon-key]. The view marks the active item
1399
- # by comparing the path and renders the icon via `nav_icon`.
1594
+ # Sidebar navigation, grouped by operator intent (operate / review /
1595
+ # build / insights / platform). Each item: [label, href, icon-key] plus an
1596
+ # optional pending count (Review items) the layout shows as a badge. The
1597
+ # view marks the active item by comparing the path and renders the icon via
1598
+ # `nav_icon`.
1400
1599
  def nav_sections
1600
+ counts = review_counts
1401
1601
  [
1402
1602
  ["", [
1403
1603
  ["Home", "/studio/home", :home]
1404
1604
  ]],
1605
+ ["operate", [
1606
+ ["Live", "/studio/live", :live],
1607
+ ["Chats", "/studio/chats", :chats],
1608
+ ["Customers", "/studio/customers", :chats],
1609
+ ["Playground", "/studio/playground", :playground]
1610
+ ]],
1611
+ ["review", [
1612
+ ["Inbox", "/studio/inbox", :approvals, counts.values.sum],
1613
+ ["Approvals", "/studio/approvals", :approvals, counts[:approvals]],
1614
+ # the human gate on distilled proposals (approve/reject/dismiss).
1615
+ ["Facts", "/studio/facts", :facts, counts[:facts]],
1616
+ # the human gate on mined skills (gate, promote, reject, rollback).
1617
+ ["Harvest", "/studio/harvest", :harvest, counts[:harvest]],
1618
+ ["Refinement", "/studio/refinement", :refinement, counts[:refinement]]
1619
+ ]],
1405
1620
  ["build", [
1406
1621
  ["Agents", "/studio/agents", :agents],
1407
1622
  ["Skills", "/studio/skills", :skills],
1623
+ ["Kits", "/studio/kits", :kits],
1408
1624
  ["Tools", "/studio/tools", :tools],
1409
- ["System files", "/studio/system-files", :system]
1410
- ]],
1411
- ["runtime", [
1412
1625
  ["MCP", "/studio/mcp", :mcp],
1413
- ["Settings", "/studio/settings", :settings]
1626
+ ["System files", "/studio/system-files", :system]
1414
1627
  ]],
1415
- ["operate", [
1416
- ["Chats", "/studio/chats", :chats],
1417
- ["Customers", "/studio/customers", :chats],
1418
- ["Playground", "/studio/playground", :playground],
1628
+ ["insights", [
1419
1629
  ["Funnel", "/studio/funnel", :funnel],
1420
- # the Follow-ups page — scheduled, fired, blocked and
1421
- # cancelled records per agent + the per-arm A/B readout card.
1630
+ # scheduled, fired, blocked and cancelled follow-ups per agent.
1422
1631
  ["Follow-ups", "/studio/followups", :followups],
1423
- # the Artifacts page — the report destination: per-agent
1424
- # list, sandboxed preview, signed sharing link.
1632
+ # the report destination: per-agent list, preview, signed link.
1425
1633
  ["Artifacts", "/studio/artifacts", :artifacts],
1426
- # the Facts (wiki) page — the human gate on distilled
1427
- # proposals (approve/reject/dismiss, the latch, the CAS re-present).
1428
- ["Facts", "/studio/facts", :facts],
1429
- # the Harvest page — the human gate on mined skills
1430
- # (gate, promote, reject, rollback; the append-only promotion log).
1431
- ["Harvest", "/studio/harvest", :harvest],
1432
- # the Knowledge page — concepts the engine extracted from
1433
- # finished conversations; edit, delete, resolve a conflict.
1634
+ # concepts the engine extracted from finished conversations.
1434
1635
  ["Knowledge", "/studio/knowledge", :knowledge],
1435
- ["Tasks", "/studio/tasks", :tasks],
1436
- ["Approvals", "/studio/approvals", :approvals],
1437
- ["Refinement", "/studio/refinement", :refinement],
1438
- ["Evals", "/studio/evals", :evals]
1439
- ] + parity_nav_item
1440
- ]
1636
+ ["Evals", "/studio/evals", :evals],
1637
+ ["Tasks", "/studio/tasks", :tasks]
1638
+ ] + parity_nav_item],
1639
+ ["platform", [
1640
+ ["Models", "/studio/models", :settings],
1641
+ ["Settings", "/studio/settings", :settings]
1642
+ ]]
1441
1643
  ]
1442
1644
  end
1443
1645
 
1646
+ # What the ⌘K palette lists: [group, label, href] for every nav page,
1647
+ # then every agent.
1648
+ def palette_entries
1649
+ pages = nav_sections.flat_map do |group, items|
1650
+ items.map { |label, href, _| [group, label, href] }
1651
+ end
1652
+ agents = Array(insika[:profile_source]&.ids).sort.map { |id| ["agent", id, "/studio/agents/#{Rack::Utils.escape_path(id)}"] }
1653
+ pages + agents
1654
+ end
1655
+
1656
+ # The Overview's "Needs attention" row: [text, href, tone] for every
1657
+ # non-zero item — review queues, customers waiting on a reply, tools that
1658
+ # failed in the last 24h. `turns` is the metrics store's 24h turn block.
1659
+ def attention_items(turns)
1660
+ counts = review_counts
1661
+ failing = Array(turns && turns["tools"]).count { |t| t["failures"].to_i.positive? }
1662
+ [
1663
+ [counts[:approvals], "approval(s) waiting", "/studio/approvals", "warn"],
1664
+ [counts[:facts], "fact proposal(s) to review", "/studio/facts", "info"],
1665
+ [counts[:harvest], "mined skill(s) to review", "/studio/harvest", "info"],
1666
+ [counts[:refinement], "refinement run(s) to review", "/studio/refinement", "info"],
1667
+ [counts[:false_positives], "guardrail false positive(s) reported", "/studio/inbox?type=false_positive", "err"],
1668
+ [Array(@stalled).size, "customer(s) waiting for a reply", "/studio/chats", "warn"],
1669
+ [failing, "tool(s) failing in 24h", "/studio/tools", "err"]
1670
+ ].select { |n, *| n.positive? }.map { |n, text, href, tone| ["#{n} #{text}", href, tone] }
1671
+ end
1672
+
1673
+ # Items parked on a human, per Review page — the sidebar badges.
1674
+ def review_counts = review_queues.transform_values(&:size)
1675
+
1676
+ # The Review queues themselves, read once per request (the sidebar and the
1677
+ # inbox share them); a store that is missing or fails is empty (the nav
1678
+ # never breaks a page).
1679
+ def review_queues
1680
+ @review_queues ||= {
1681
+ approvals: safe_list { insika[:pending_action_store]&.all_open(kind: Insika::PendingActionStore::OPERATOR) },
1682
+ facts: safe_list { insika[:proposal_store]&.pending(limit: 100) },
1683
+ harvest: safe_list { insika[:harvest_store]&.awaiting_approval(limit: 100) },
1684
+ refinement: safe_list { insika[:refinement_store]&.awaiting_approval(limit: 100) },
1685
+ false_positives: safe_list { reported_sessions }
1686
+ }
1687
+ end
1688
+
1689
+ # Every session's stats, read once per request: the sidebar badge, the
1690
+ # inbox and the page itself all ask for them.
1691
+ def session_stats
1692
+ @session_stats ||= insika[:session_store].all_stats
1693
+ end
1694
+
1695
+ # Sessions holding a guardrail flag someone reported as a false positive
1696
+ # and nobody resolved yet (stats only).
1697
+ def reported_sessions
1698
+ store = insika[:session_store]
1699
+ store.respond_to?(:all_stats) ? session_stats.select { |s| s.reported.positive? } : []
1700
+ end
1701
+
1702
+ INBOX_TYPES = { "approval" => "Approval", "fact" => "Fact", "skill" => "Skill",
1703
+ "refinement" => "Refinement", "false_positive" => "False positive" }.freeze
1704
+
1705
+ # The Review inbox: every item parked on a person, oldest first, each
1706
+ # linking to the page where it is decided. -> [{ type:, title:, agent:, at:, href: }]
1707
+ def inbox_items
1708
+ tasks = insika[:task_store]
1709
+ queues = review_queues
1710
+ items = []
1711
+ queues[:approvals].each do |pa|
1712
+ items << { type: "approval", title: "#{pa.tool} needs approval", agent: (task = tasks&.find(pa.task_id)) && task_agent(task),
1713
+ at: pa.requested_at, href: "/studio/approvals" }
1714
+ end
1715
+ queues[:facts].each do |p|
1716
+ items << { type: "fact", title: "#{p.key}: #{p.value}"[0, 120], agent: p.scope,
1717
+ at: p.created_at, href: "/studio/facts/#{Rack::Utils.escape_path(p.id)}" }
1718
+ end
1719
+ queues[:harvest].each do |c|
1720
+ items << { type: "skill", title: "Mined skill: #{c.name}", agent: c.agent,
1721
+ at: c.updated_at, href: "/studio/harvest?agent=#{Rack::Utils.escape(c.agent.to_s)}" }
1722
+ end
1723
+ queues[:refinement].each do |run|
1724
+ items << { type: "refinement", title: "Refinement run waiting for a decision", agent: run.agent_id,
1725
+ at: run.finished_at || run.started_at, href: "/studio/refinement/#{Rack::Utils.escape_path(run.id)}" }
1726
+ end
1727
+ queues[:false_positives].each do |st|
1728
+ who = presence(st.vars["customer_name"]) || presence(st.vars["customer"]) || st.id
1729
+ items << { type: "false_positive", title: "Guardrail false positive · #{who}", agent: st.vars["agent"],
1730
+ at: st.reported_at || st.updated_at, href: "/studio/sessions/#{Rack::Utils.escape_path(st.id)}" }
1731
+ end
1732
+ items.sort_by { |i| i[:at].to_s }
1733
+ end
1734
+
1735
+ def render_inbox
1736
+ all = inbox_items
1737
+ @inbox_counts = INBOX_TYPES.keys.to_h { |t| [t, all.count { |i| i[:type] == t }] }
1738
+ @inbox_type = INBOX_TYPES.key?(request.params["type"]) ? request.params["type"] : nil
1739
+ @inbox_total = all.size
1740
+ @items = @inbox_type ? all.select { |i| i[:type] == @inbox_type } : all
1741
+ view("inbox")
1742
+ end
1743
+
1744
+ def safe_list
1745
+ Array(yield)
1746
+ rescue StandardError
1747
+ []
1748
+ end
1749
+
1444
1750
  # NAV_ICONS + nav_icon moved to Studio::NavIcons.
1445
1751
 
1446
1752
  def authenticated? = session["auth"] == true
@@ -1465,6 +1771,25 @@ end
1465
1771
  path == target || path.start_with?("#{target}/")
1466
1772
  end
1467
1773
 
1774
+ # The nav group the current page sits in ("operate" / "review" / "build" / …),
1775
+ # or nil for the ungrouped Home.
1776
+ def nav_group
1777
+ group, = nav_sections.find { |_, items| items.any? { |(_, href, _)| nav_active?(href) } }
1778
+ presence(group)
1779
+ end
1780
+
1781
+ # The one-segment breadcrumb above a page title. It is always the nav GROUP,
1782
+ # never the page's own name: a crumb that echoes the title ("knowledge /
1783
+ # Knowledge") tells the operator nothing. Renders "" when there is no group,
1784
+ # or when the group would only repeat the title. Pages with a real hierarchy
1785
+ # (agents / :id, chats / session) spell their crumbs out in the view.
1786
+ def crumbs_for(title)
1787
+ group = nav_group
1788
+ return "" if group.nil? || group.casecmp?(title.to_s)
1789
+
1790
+ %(<nav class="crumbs"><span>#{h(group)}</span></nav>)
1791
+ end
1792
+
1468
1793
  # CSP nonce for inline styles (CodeMirror's injected theme). Stable PER SESSION,
1469
1794
  # not per request: the browser enforces the CSP from the initial document load
1470
1795
  # for the whole SPA session, but Turbo Drive fetches later pages carrying their
@@ -1484,6 +1809,15 @@ end
1484
1809
  # they are looking at. No new state; just reads the process env.
1485
1810
  def env_label = (presence(Insika::EnvSchema.read("INSIKA_ENV")) || presence(ENV["RACK_ENV"]) || "local").to_s
1486
1811
 
1812
+ # The top-of-page environment strip: nil on production (no strip), else
1813
+ # the label plus a style — staging amber, anything else (dev, local) blue.
1814
+ def env_banner
1815
+ label = env_label.downcase
1816
+ return nil if %w[production prod].include?(label)
1817
+
1818
+ { label: label, kind: label.start_with?("stag") ? "staging" : "other" }
1819
+ end
1820
+
1487
1821
  # Theme preference read from the cookie (applied server-side on <html> → no
1488
1822
  # flash). Strict allowlist: an unexpected value falls back to "auto".
1489
1823
  THEMES = %w[auto light dark].freeze
@@ -1625,6 +1959,10 @@ end
1625
1959
 
1626
1960
  def render_agent_detail
1627
1961
  id = @agent.id
1962
+ # The config sections show what the agent runs with: its own values, or
1963
+ # the platform's where it inherits (the inherit boxes read @agent_own).
1964
+ @agent_own = @agent
1965
+ @agent = Insika::AgentDefaults.apply_safely(@agent, platform_agent_defaults)
1628
1966
  # Which subnav tab the frame should land on. Selecting a prompt file or
1629
1967
  # a config group is a real navigation (advances the frame + history),
1630
1968
  # which reconnects the `tabs` Stimulus controller — but Turbo's history
@@ -1636,6 +1974,8 @@ end
1636
1974
  "prompts"
1637
1975
  elsif request.params["cfg"]
1638
1976
  "config"
1977
+ elsif %w[tools skills].include?(request.params["tab"])
1978
+ request.params["tab"]
1639
1979
  end
1640
1980
  @config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
1641
1981
  store = insika[:agent_file_store]
@@ -1643,11 +1983,13 @@ end
1643
1983
  {
1644
1984
  name: name.to_s,
1645
1985
  content: store&.read(id, name).to_s,
1986
+ updated_at: store&.updated_at(id, name),
1646
1987
  versions: store ? store.versions(id, name) : []
1647
1988
  }
1648
1989
  end
1649
- @all_skills = insika[:skill_catalog]&.all || []
1990
+ @all_skills = insika[:skill_catalog]&.all(agent: id) || []
1650
1991
  @agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
1992
+ load_tool_catalog
1651
1993
  # v2 config surfaces: generation params + model fence. AgentProfile.build
1652
1994
  # string-keys these hashes, so the form helpers read plain string keys.
1653
1995
  @params = @agent.params
@@ -1657,10 +1999,17 @@ end
1657
1999
  mem = insika[:memory_store]
1658
2000
  @facts = mem ? mem.facts(tenant: id) : []
1659
2001
  @notes = mem ? mem.notes(tenant: id, limit: 20) : []
1660
- @recent_sessions = recent_sessions
2002
+ # Sessions stamp their agent (vars["agent"]); keep this agent's only.
2003
+ # ponytail: scans the 200 most recent sessions; a per-agent index if an
2004
+ # agent's conversations fall out of that window.
2005
+ @recent_sessions = agent_sessions(recent_sessions(limit: 200), id).first(8)
1661
2006
  # WS7: last outcome + per-day series for THIS agent. The grid already
1662
2007
  # shows the last-outcome pill; the series is the period view.
1663
2008
  outcomes = insika[:outcome_store]
2009
+ # one read of this agent's outcomes, shared by the performance and History tabs
2010
+ mine = Array(outcomes&.all(agent: id))
2011
+ agent_performance(id, mine)
2012
+ agent_changes(id, mine)
1664
2013
  @latest_outcome = outcomes&.latest_per_agent&.[](id)
1665
2014
  @outcome_series = outcomes ? outcomes.series(agent: id) : {}
1666
2015
  # the per-agent cache-hit series (nil store -> the view's
@@ -1672,7 +2021,6 @@ end
1672
2021
  # the master column: the detail page IS the shell when
1673
2022
  # visited directly; only a frame request renders the pane alone.
1674
2023
  @agents = insika[:profile_source].all.sort_by(&:id)
1675
- @latest_outcomes = insika[:outcome_store]&.latest_per_agent
1676
2024
  if turbo_frame?("agent-detail")
1677
2025
  render("agent_detail", locals: { frame_only: true }, layout: false)
1678
2026
  else
@@ -1699,6 +2047,33 @@ end
1699
2047
  config.to_s
1700
2048
  end
1701
2049
 
2050
+ # "Use the platform default" box of an inheritable config section, on the
2051
+ # agent page only. Checked while the agent has no value of its own.
2052
+ def inherit_toggle(field)
2053
+ return "" if @agent_own.nil?
2054
+
2055
+ checked = @agent_own.public_send(field).nil? ? " checked" : ""
2056
+ %(<label class="check inherit"><input type="checkbox" name="inherit[]" value="#{field}"#{checked}> ) +
2057
+ %(use the platform default <span class="muted">(Settings › Agent defaults)</span></label>)
2058
+ end
2059
+
2060
+ def platform_agent_defaults
2061
+ store = insika[:settings_store]
2062
+ (store ? store.get : Insika::SettingsStore::DEFAULTS)["agent_defaults"] || {}
2063
+ end
2064
+
2065
+ # The platform defaults as a profile, so the agent's sections render them.
2066
+ # A field that no longer builds (a rerank model the registry dropped) shows
2067
+ # blank, so this page stays the place to fix it.
2068
+ def agent_defaults_profile
2069
+ fields = platform_agent_defaults.slice(*Insika::AgentDefaults::FIELDS).select do |field, value|
2070
+ Insika::AgentDefaults.validate!(field => value)
2071
+ rescue Insika::ValidationError
2072
+ false
2073
+ end
2074
+ Insika::AgentProfile.build(id: "agent-defaults", model: nil, **fields.transform_keys(&:to_sym))
2075
+ end
2076
+
1702
2077
  # A list field as comma-joined text for the form's textarea.
1703
2078
  def agent_list(list)
1704
2079
  Array(list).join(", ")
@@ -1713,12 +2088,10 @@ end
1713
2088
  # (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
1714
2089
  # facts, parity). Same markup everywhere: a select that submits on change.
1715
2090
  def agent_filter_form(path, current)
1716
- ids = insika[:profile_source].ids.sort
1717
- options = [["", "all"]] + ids.map { |id| [id, id] }
1718
- rows = options.map do |value, label|
1719
- %(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
1720
- end.join
1721
- %(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" data-controller="auto-submit" data-action="change->auto-submit#submit">#{rows}</select></label></form>)
2091
+ picker = render("_agent_picker", locals: { name: "agent", options: insika[:profile_source].ids.sort,
2092
+ value: current, blank: "All agents — type to search",
2093
+ auto_submit: true })
2094
+ %(<form method="get" action="#{path}" class="actions inline"><label>Agent #{picker}</label></form>)
1722
2095
  end
1723
2096
 
1724
2097
  # The sessions of one agent — the session stamps its agent in
@@ -1734,6 +2107,34 @@ end
1734
2107
  vars.is_a?(Hash) ? vars["agent"].to_s : ""
1735
2108
  end
1736
2109
 
2110
+ # The customer a session belongs to — blank for a session that never got
2111
+ # far enough to identify one (a fresh playground run, an abandoned turn).
2112
+ # Same vars lookup as session_agent, one key over.
2113
+ def session_customer(session)
2114
+ vars = session.respond_to?(:vars) ? session.vars : nil
2115
+ vars.is_a?(Hash) ? vars["customer"].to_s : ""
2116
+ end
2117
+
2118
+ # The label a conversation shows: the caller's customer_name, else the
2119
+ # customer key. Blank when neither was sent.
2120
+ def session_customer_label(session)
2121
+ vars = session.respond_to?(:vars) ? session.vars : nil
2122
+ presence(vars.is_a?(Hash) ? vars["customer_name"] : nil) || session_customer(session)
2123
+ end
2124
+
2125
+ def session_customer_phone(session)
2126
+ vars = session.respond_to?(:vars) ? session.vars : nil
2127
+ presence(vars.is_a?(Hash) ? vars["customer_phone"] : nil)
2128
+ end
2129
+
2130
+ # The Chats/History "active" presence dot: lit while the session's last
2131
+ # activity sits inside the same 5-minute window the home dashboard's
2132
+ # conversation rail uses (views/home.erb) — one rule, read in both places.
2133
+ def session_fresh?(session, cutoff: Time.now - 300)
2134
+ t = parse_time(session.updated_at)
2135
+ !t.nil? && t >= cutoff
2136
+ end
2137
+
1737
2138
  # The distill button's scope (the DistillEngine's own scan, scoped to ONE
1738
2139
  # agent): the agent's sessions that are idle past the pack's window, long
1739
2140
  # enough, and not yet distilled. Oldest first, capped — each entry is a
@@ -1749,9 +2150,8 @@ end
1749
2150
  idle_hours = positive_int(config["idle_hours"]) || 6
1750
2151
  min_messages = positive_int(config["min_messages"]) || 3
1751
2152
  cutoff = Time.now.utc - idle_hours * 3600
1752
- store.each_id.filter_map do |sid|
1753
- session = store.find(sid)
1754
- next unless session
2153
+ every_record(store).filter_map do |session|
2154
+ sid = session.id
1755
2155
  next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
1756
2156
  next if Insika::Coercion.blank?(session.vars["customer"])
1757
2157
  next unless aged?(session.updated_at, cutoff)
@@ -1788,6 +2188,68 @@ end
1788
2188
  %(<select name="#{name}">#{rows}</select>)
1789
2189
  end
1790
2190
 
2191
+ # The model pickers offer ONLY refs that resolve: each Studio provider's declared
2192
+ # models, plus the chat models of every provider RubyLLM holds a key for that the
2193
+ # Studio does not declare (e.g. a DEEPSEEK_API_KEY deployment). A typed ref is how
2194
+ # "openai/gpt-x" under provider "openai" reached the API as "openai/openai/gpt-x".
2195
+ # -> ["provider/model", ...] sorted.
2196
+ def model_refs
2197
+ @model_refs ||= begin
2198
+ providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
2199
+ declared = providers.flat_map { |p| Array(p["models"]).map { |m| "#{p['api']}/#{m}" } }
2200
+ apis = providers.map { |p| p["api"].to_s }
2201
+ native = RubyLLM::Provider.configured_providers(RubyLLM.config).flat_map do |klass|
2202
+ slug = klass.slug.to_s
2203
+ apis.include?(slug) ? [] : RubyLLM.models.by_provider(slug).chat_models.map { |m| "#{slug}/#{m.id}" }
2204
+ end
2205
+ (declared + native).uniq.sort
2206
+ end
2207
+ end
2208
+
2209
+ # Type-to-search picker over model_refs (the _agent_picker idiom: <input list>
2210
+ # over a <datalist>, native, no JS). The datalist suggests; the SERVER decides —
2211
+ # `checked_model_ref` refuses a typed ref outside the list. A stored value outside
2212
+ # it is shown as is, marked, and may be saved back unchanged.
2213
+ def model_select(name, current, blank_label)
2214
+ cur = current.to_s
2215
+ esc = ->(s) { Rack::Utils.escape_html(s.to_s) }
2216
+ list_id = "model-options-#{@model_picker_seq = (@model_picker_seq || 0) + 1}"
2217
+ options = model_refs.map { |ref| %(<option value="#{esc.(ref)}"></option>) }.join
2218
+ stale = cur.empty? || model_refs.include?(cur) ? "" : %( <span class="muted">not configured</span>)
2219
+ %(<input type="search" class="filter-input" name="#{esc.(name)}" list="#{list_id}" value="#{esc.(cur)}" ) +
2220
+ %(autocomplete="off" placeholder="#{esc.(blank_label)} — type to search">#{stale}) +
2221
+ %(<datalist id="#{list_id}">#{options}</datalist>)
2222
+ end
2223
+
2224
+ # An ORDERED list of refs (fallback chains, proposers): one picker per slot,
2225
+ # posted as "<name>[]", always one empty slot more than stored (min `min`).
2226
+ def model_slots(name, values, min: 3)
2227
+ values = Array(values)
2228
+ slots = [values.size + 1, min].max.times.map do |i|
2229
+ %(<label>#{i + 1}. #{model_select("#{name}[]", values[i], 'empty')}</label>)
2230
+ end
2231
+ %(<div class="grid-2">#{slots.join}</div>)
2232
+ end
2233
+
2234
+ # A picked ref must be one the picker offers; `kept` (the stored value) may come
2235
+ # back unchanged even when it is no longer listed. Blank = nil (inherit/none).
2236
+ def checked_model_ref(raw, kept = nil)
2237
+ ref = presence(raw)
2238
+ return ref if ref.nil? || model_refs.include?(ref) || Array(kept).include?(ref)
2239
+
2240
+ raise Insika::ValidationError, "unknown model '#{ref}' — pick one from the list (Settings › LLM providers)"
2241
+ end
2242
+
2243
+ # "provider/model" for a stored pair (the agent and the platform default keep
2244
+ # them apart); a bare model when there is no provider.
2245
+ def model_ref_of(model, provider)
2246
+ m = model.to_s
2247
+ p = provider.to_s
2248
+ return "" if m.empty?
2249
+
2250
+ p.empty? ? m : "#{p}/#{m}"
2251
+ end
2252
+
1791
2253
  # Renders the per-model reasoning map ({ "ref" => { "thinking" => v } }) back to
1792
2254
  # the textarea lines "ref | thinking" that model_params_patch parses. Only refs
1793
2255
  # with a set thinking are shown (blank ones are inherit no-ops).
@@ -1841,6 +2303,38 @@ end
1841
2303
  # Which agents specialized THIS skill — the availability grid shows it, so an
1842
2304
  # override is discoverable from the shared skill it overrides.
1843
2305
  @specialized = insika[:skill_store] ? specialized_by : {}
2306
+ @skill_reach = skill_reach(@agents, @skills)
2307
+ @skill_groups = skill_groups(@skills)
2308
+ end
2309
+
2310
+ # { skill name => [agent ids that can load it] }: own list + kits, or "all".
2311
+ def skill_reach(agents, skills)
2312
+ reach = Hash.new { |h, k| h[k] = [] }
2313
+ agents.each do |own|
2314
+ p = Insika::Kits.apply(own, kits_by_name)
2315
+ allowed = p.skills.nil? ? nil : Array(p.skills).map(&:to_s)
2316
+ skills.each { |s| reach[s.name] << p.id if allowed.nil? || allowed.include?(s.name) }
2317
+ end
2318
+ reach
2319
+ end
2320
+
2321
+ # [[label, skills]]: skills sharing a name prefix ("grocery-core",
2322
+ # "grocery-offers" -> "grocery") group together; the rest go to "Other", last.
2323
+ def skill_groups(skills)
2324
+ prefix = ->(s) { s.name.include?("-") ? s.name.split("-").first : nil }
2325
+ shared = skills.filter_map { |s| prefix.(s) }.tally.select { |_, n| n > 1 }.keys
2326
+ skills.group_by { |s| shared.include?(prefix.(s)) ? prefix.(s) : "Other" }
2327
+ .sort_by { |label, _| [label == "Other" ? 1 : 0, label] }
2328
+ end
2329
+
2330
+ # Kits drill: master = every kit; the editor checks skills/tools/groups from the
2331
+ # live catalogs. @selected nil = none open, "" = new.
2332
+ def load_kits
2333
+ @kits = (insika[:settings_store]&.kits || {}).sort.to_h
2334
+ @skills = (insika[:skill_catalog]&.all || []).sort_by(&:name)
2335
+ @tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
2336
+ @tool_group_names = @tools.filter_map { |t| t.plugin.to_s if t.plugin.to_s.start_with?("mcp:") }.uniq.sort
2337
+ @agents = insika[:profile_source].all.sort_by(&:id)
1844
2338
  end
1845
2339
 
1846
2340
  # { skill name => [agent ids] } across every agent scope in the store.
@@ -1862,6 +2356,31 @@ end
1862
2356
  # Raw content for the editor: prefer the store (real SKILL.md), otherwise
1863
2357
  # reconstruct from what the catalog parsed (editing a disk skill
1864
2358
  # creates an override in the store — Store wins).
2359
+ # -> [ { "content" =>, "from" =>, "to" => } ] most recent first, the current
2360
+ # one included (to: nil). Each version was in use from its write until the
2361
+ # next one, so a turn's time picks the version it ran. Disk-only skill: [].
2362
+ def skill_versions(name, agent: nil)
2363
+ store = insika[:skill_store]
2364
+ current = store&.get(name, agent: agent)
2365
+ return [] unless current
2366
+
2367
+ all = [{ "content" => current, "at" => store.updated_at(name, agent: agent) }] + store.versions(name, agent: agent)
2368
+ all.each_with_index.map do |v, i|
2369
+ { "content" => v["content"], "from" => v["at"], "to" => i.zero? ? nil : all[i - 1]["at"] }
2370
+ end
2371
+ end
2372
+
2373
+ # Restoring is writing an old text again: the same command as a save, so the
2374
+ # catalog reloads and the old version stays in the history. `version` indexes
2375
+ # SkillStore#versions (0 = the one before the current).
2376
+ def restore_skill_version(name, version, agent: nil)
2377
+ i = Integer(version.to_s, exception: false)
2378
+ old = i&.>=(0) ? insika[:skill_store]&.versions(name, agent: agent)&.[](i) : nil
2379
+ raise Insika::ValidationError, "version #{version} does not exist" unless old
2380
+
2381
+ dispatch(:write_skill, { name: name, agent: agent, content: old["content"] })
2382
+ end
2383
+
1865
2384
  def skill_source(name, agent: nil)
1866
2385
  raw = insika[:skill_store]&.get(name, agent: agent)
1867
2386
  return raw if raw
@@ -1916,22 +2435,39 @@ end
1916
2435
 
1917
2436
  # --- Tools matrix --------------------------------------------------------
1918
2437
 
1919
- def render_tools_matrix
2438
+ # The live tool catalog, grouped by origin (Native / HTTP / MCP: x). Feeds the
2439
+ # Tools page and the agent's Tools tab.
2440
+ def load_tool_catalog
1920
2441
  @tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
1921
2442
  # Names of the DATA-DEFINED tools (editable via the UI). The rest of the catalog are
1922
2443
  # code tools (allow/deny only). Used to mark and link the editor.
1923
2444
  @data_tool_names = insika[:tool_store] ? insika[:tool_store].names : []
2445
+ # A data tool's `group` (commerce, b2b, groceries…) splits the HTTP section, so a
2446
+ # catalog with every store type's tools reads by type instead of one long list.
2447
+ data_groups = (insika[:tool_store]&.all || []).to_h { |d| [(d["name"] || d[:name]).to_s, (d["group"] || d[:group]).to_s] }
2448
+ @tool_groups = @tools.group_by do |tool|
2449
+ if tool.plugin.to_s.start_with?("mcp:")
2450
+ "MCP: #{tool.plugin.delete_prefix('mcp:')}"
2451
+ elsif @data_tool_names.include?(tool.name)
2452
+ group = data_groups[tool.name]
2453
+ group.to_s.empty? ? "HTTP tools" : "HTTP: #{group}"
2454
+ else
2455
+ "Native tools"
2456
+ end
2457
+ end.sort_by { |label, _| [label == "Native tools" ? 0 : label.start_with?("HTTP") ? 1 : 2, label] }
1924
2458
  # Stored but NOT in the catalog = the overlay refused the definition and dropped it
1925
2459
  # (only a stderr warn otherwise). The pane still links its editor, so the panel is
1926
2460
  # where you see it and where you fix it. `insika doctor` reports the same set.
1927
2461
  @dropped_tool_names = @data_tool_names - @tools.map(&:name)
1928
- @agents = insika[:profile_source].all.sort_by(&:id)
1929
- # Drill-down: ?a= selects the agent whose allow/deny matrix fills the detail;
1930
- # default to the first agent so the pane is useful on landing.
1931
- sel = request.params["a"]
1932
- @sel_agent = (sel && @agents.find { |a| a.id == sel }) || @agents.first
1933
- # Miller columns: the master rows frame-navigate (?a=),
1934
- # so a Turbo-Frame request renders just the detail pane.
2462
+ end
2463
+
2464
+ # The Tools page: catalog master + one detail (nil landing, :edit, :info). A
2465
+ # Turbo-Frame request renders just the detail pane.
2466
+ def render_tools_page(detail: nil, selected: nil)
2467
+ load_tool_catalog unless @tools
2468
+ @tool_reach = tool_reach(insika[:profile_source].all, @tools)
2469
+ @detail = detail
2470
+ @selected = selected
1935
2471
  if turbo_frame?("tool-detail")
1936
2472
  render("tools", locals: { frame_only: true }, layout: false)
1937
2473
  else
@@ -1939,6 +2475,57 @@ end
1939
2475
  end
1940
2476
  end
1941
2477
 
2478
+ # { tool name => [agent ids that can call it] }: the agent's own list, its kits,
2479
+ # its tool groups, or "all"; deny wins. The same reading the turn's
2480
+ # ToolAllowlist policy makes, over the catalog.
2481
+ def tool_reach(agents, tools)
2482
+ reach = Hash.new { |h, k| h[k] = [] }
2483
+ agents.sort_by(&:id).each do |own|
2484
+ p = Insika::Kits.apply(own, kits_by_name)
2485
+ open = p.tools_allow.nil? && p.tools_allow_groups.nil?
2486
+ allow = Array(p.tools_allow).map(&:to_s)
2487
+ groups = Array(p.tools_allow_groups).map(&:to_s)
2488
+ deny = Array(p.tools_deny).map(&:to_s)
2489
+ tools.each do |t|
2490
+ next if deny.include?(t.name)
2491
+
2492
+ reach[t.name] << p.id if open || allow.include?(t.name) || groups.include?(t.plugin.to_s)
2493
+ end
2494
+ end
2495
+ reach
2496
+ end
2497
+
2498
+ # Kits as stored (Settings["kits"]), read once per request.
2499
+ def kits_by_name = (@kits_by_name ||= insika[:settings_store]&.kits || {})
2500
+
2501
+ # { item name => kit name } for what the agent gets from its kits (key "tools" or
2502
+ # "skills"). The views show these on and locked: they come from the kit at turn
2503
+ # time, never from the agent's own list, so a save must not post them.
2504
+ def kit_sources(profile, key)
2505
+ Array(profile.kits).each_with_object({}) do |name, acc|
2506
+ kit = kits_by_name[name]
2507
+ next unless kit.is_a?(Hash)
2508
+
2509
+ Array(kit[key]).each { |item| acc[item] ||= name }
2510
+ end
2511
+ end
2512
+
2513
+ # "3 tools · 2 skills" (own lists + kits; "all" when the list is open) for the
2514
+ # agents master row.
2515
+ def agent_reach(own)
2516
+ p = Insika::Kits.apply(own, kits_by_name)
2517
+ groups = Array(p.tools_allow_groups).size
2518
+ tools = if p.tools_allow.nil? && p.tools_allow_groups.nil? then "all tools"
2519
+ else "#{Array(p.tools_allow).size} tools#{" + #{groups} #{groups == 1 ? "server" : "servers"}" if groups.positive?}"
2520
+ end
2521
+ skills = p.skills.nil? ? "all skills" : "#{Array(p.skills).size} skills"
2522
+ "#{tools} · #{skills}"
2523
+ end
2524
+
2525
+ # The agent can call every tool or load every skill: kits add nothing to it and
2526
+ # any tool or skill added later reaches it without anyone deciding.
2527
+ def agent_open?(own) = (own.tools_allow.nil? && own.tools_allow_groups.nil?) || own.skills.nil?
2528
+
1942
2529
  # nil = all; otherwise the list. Pre-checks the checkboxes per agent.
1943
2530
  def tool_allowed_for?(profile, tool_name)
1944
2531
  profile.tools_allow.nil? || Array(profile.tools_allow).map(&:to_s).include?(tool_name.to_s)
@@ -1966,7 +2553,7 @@ end
1966
2553
  @tool_name = name
1967
2554
  @form = tool_form(tool)
1968
2555
  @versions = tool && insika[:tool_store] ? insika[:tool_store].versions(name) : []
1969
- view("tool_edit")
2556
+ render_tools_page(detail: :edit, selected: name)
1970
2557
  end
1971
2558
 
1972
2559
  # Definition (masked) -> Hash of text fields ready for the form. tool=nil
@@ -2069,13 +2656,22 @@ end
2069
2656
  # `?agent=` narrows every number to one agent (the sessions stamp their
2070
2657
  # author in `vars["agent"]`). The live layer (live_home_controller) only
2071
2658
  # repaints what this renders — it never computes its own baseline.
2659
+ HOME_WINDOW_DAYS = 14
2660
+
2661
+ # Everything on the home is read from the sessions of the last 14 days — the
2662
+ # window the charts already show — never from every session ever stored. The
2663
+ # conversations total comes from the keys alone (no record read); with an agent
2664
+ # filter it is the window's, since the agent lives inside the record.
2072
2665
  def render_home
2073
2666
  ps = insika[:profile_source]
2074
2667
  @agent = presence(request.params["agent"])
2075
- sessions = agent_sessions(all_sessions, @agent)
2668
+ now = Time.now.utc
2669
+ window = window_sessions(now)
2670
+ sessions = agent_sessions(window, @agent)
2671
+ home_operations(window, now, ps)
2076
2672
  @counts = {
2077
- "conversations" => sessions.size,
2078
- "messages" => sessions.sum { |s| Array(s.messages).size },
2673
+ "conversations" => @agent ? sessions.size : session_total,
2674
+ "messages" => sessions.sum { |s| message_count(s) },
2079
2675
  "agents" => ps ? ps.all.size : 0,
2080
2676
  "skills" => insika[:skill_catalog] ? insika[:skill_catalog].all.size : 0,
2081
2677
  "tools" => insika[:tool_catalog] ? insika[:tool_catalog].all.size : 0,
@@ -2089,10 +2685,12 @@ end
2089
2685
  # matching and the 24h floor was built three hours in the future, silently
2090
2686
  # emptying both charts. Instant comparisons (`cutoff`) never had the bug;
2091
2687
  # calendar arithmetic did.
2092
- now = Time.now.utc
2093
2688
  cutoff = now - (5 * 60)
2094
- @active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
2689
+ @active_now = sessions.count { |s| (t = utc_time(s.updated_at)) && t >= cutoff }
2690
+ # the window holds stats; the short list needs the sessions themselves
2095
2691
  @recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
2692
+ .filter_map { |s| s.respond_to?(:messages) ? s : insika[:session_store].find(s.id) }
2693
+ @recent = agent_sessions(recent_sessions(limit: 8), @agent) if @recent.empty?
2096
2694
  @activity = activity_by_day(sessions, days: 14, now: now)
2097
2695
  # 24h sparkline: conversations touched per hour, oldest
2098
2696
  # first — the same session scan, bucketed finer.
@@ -2110,11 +2708,152 @@ end
2110
2708
  view("home")
2111
2709
  end
2112
2710
 
2113
- def all_sessions
2711
+ # The agent page's Performance tab: per fixed period (24h/7d/30d) the
2712
+ # conversations and messages it touched (session stats), its outcomes, and
2713
+ # the model/turn numbers from ModelMetricsStore#windows (one read).
2714
+ def agent_performance(id, records)
2715
+ now = Time.now.utc
2716
+ @perf = insika[:model_metrics_store]&.windows(agent: id, now: now) || {}
2717
+ store = insika[:session_store]
2718
+ stats = if store.nil? then []
2719
+ elsif store.respond_to?(:all_stats) then session_stats
2720
+ else every_record(store)
2721
+ end
2722
+ mine = agent_sessions(stats, id)
2723
+ outcome_times = records.map { |r| r.at.to_s }
2724
+ @perf_activity = Insika::ModelMetricsStore::PERIODS.to_h do |period, seconds|
2725
+ cut = now - seconds
2726
+ touched = mine.select { |s| (t = utc_time(s.updated_at)) && t >= cut }
2727
+ [period, { conversations: touched.size, messages: touched.sum { |s| message_count(s) },
2728
+ outcomes: outcome_times.count { |at| at >= cut.iso8601 } }]
2729
+ end
2730
+ end
2731
+
2732
+ CHANGE_WINDOW_DAYS = 30
2733
+ CHANGE_COMPARE_DAYS = 7
2734
+
2735
+ # The History tab's "what changed, and what it did": outcomes per day over
2736
+ # the last 30 days (the funnel's primary outcome when declared, else all),
2737
+ # one marker per prompt-file write in that window, and per write the
2738
+ # outcome count 7 days after vs 7 days before. Plain counts; nil = that
2739
+ # window isn't whole yet (after) or predates the outcome data (before).
2740
+ def agent_changes(id, records)
2741
+ now = Time.now.utc
2742
+ week = CHANGE_COMPARE_DAYS * 86_400
2743
+ covered_from = parse_time(records.last&.at) # all() is newest first
2744
+ @change_primary = primary_stage_for(id)
2745
+ counted = @change_primary ? records.select { |r| reached_primary?(id, r) } : records
2746
+ times = counted.filter_map { |r| parse_time(r.at) }
2747
+ first_day = now.to_date - (CHANGE_WINDOW_DAYS - 1)
2748
+ @change_days = (0...CHANGE_WINDOW_DAYS).map { |i| [first_day + i, 0] }.to_h
2749
+ times.each { |t| @change_days[t.to_date] += 1 if @change_days.key?(t.to_date) }
2750
+ @change_start = Time.utc(first_day.year, first_day.month, first_day.day)
2751
+ # [current, newest older, …]: write i replaced write i + 1, which is
2752
+ # history index i — the version a prompt's Restore form puts back.
2753
+ changes_of = lambda do |label, kind, writes|
2754
+ writes.each_cons(2).each_with_index.filter_map do |(after, before), i|
2755
+ at = parse_time(after["at"])
2756
+ next unless at && at >= @change_start
2757
+
2758
+ old_lines = before["content"].to_s.lines.map(&:chomp).reject { |l| l.strip.empty? }
2759
+ new_lines = after["content"].to_s.lines.map(&:chomp).reject { |l| l.strip.empty? }
2760
+ { file: label, kind: kind, at: at, version: i,
2761
+ removed: old_lines - new_lines, added: new_lines - old_lines,
2762
+ before: covered_from && covered_from <= at - week ? times.count { |t| t >= at - week && t < at } : nil,
2763
+ after: at + week <= now ? times.count { |t| t >= at && t < at + week } : nil }
2764
+ end
2765
+ end
2766
+ prompts = @prompt_files.flat_map do |pf|
2767
+ changes_of.call(pf[:name], :prompt, [{ "content" => pf[:content], "at" => pf[:updated_at] }] + pf[:versions])
2768
+ end
2769
+ # this agent's own skill versions (shared skills change for every agent)
2770
+ skills = insika[:skill_store]
2771
+ own = skills ? skills.names(agent: id).flat_map do |name|
2772
+ changes_of.call(name, :skill, [{ "content" => skills.get(name, agent: id), "at" => skills.updated_at(name, agent: id) }] +
2773
+ skills.versions(name, agent: id))
2774
+ end : []
2775
+ @changes = (prompts + own).sort_by { |c| c[:at] }.reverse
2776
+ end
2777
+
2778
+ COLD_AGENT_DAYS = 3
2779
+ STALLED_AFTER = 10 * 60
2780
+
2781
+ def last_role(session)
2782
+ return session.last_role if session.respond_to?(:last_role)
2783
+
2784
+ last = Array(session.messages).last
2785
+ last.is_a?(Hash) ? last["role"].to_s : nil
2786
+ end
2787
+
2788
+ # The operational half of the home, all over the last 24h and the agent
2789
+ # filter: cost (vs the 24h before), outcomes, turn p95 and failures, tool
2790
+ # health — from ModelMetricsStore and OutcomeStore. The agent ranking and
2791
+ # the cold agents read the unfiltered 14-day window: they compare agents.
2792
+ def home_operations(window, now, profiles)
2793
+ metrics = insika[:model_metrics_store]
2794
+ # one read for the 24h numbers, the cost before them and the daily chart
2795
+ @day = metrics.respond_to?(:home) ? metrics.home(agent: @agent, now: now, days: HOME_WINDOW_DAYS) : nil
2796
+ @day_before_cost = @day&.fetch("day_before_cost")
2797
+ day_ago = (now - 86_400).iso8601
2798
+ @outcomes_24h = insika[:outcome_store]&.all(agent: @agent)&.count { |r| r.at.to_s >= day_ago }
2799
+ by_agent = window.group_by { |s| session_agent(s) }.reject { |agent, _| agent.empty? }
2800
+ @agent_ranking = by_agent.map do |agent, list|
2801
+ { agent: agent, conversations: list.size, today: list.count { |s| (t = utc_time(s.updated_at)) && t >= now - 86_400 },
2802
+ messages: list.sum { |s| message_count(s) } }
2803
+ end.sort_by { |row| [-row[:today], -row[:conversations], row[:agent]] }.first(8)
2804
+ # Stalled: the customer spoke last, 10+ minutes ago, within the last 24h.
2805
+ stalled_from, stalled_to = now - 86_400, now - STALLED_AFTER
2806
+ @stalled = agent_sessions(window, @agent).filter_map do |s|
2807
+ t = utc_time(s.updated_at)
2808
+ next unless t && t >= stalled_from && t <= stalled_to && last_role(s) == "user"
2809
+
2810
+ { id: s.id, agent: session_agent(s), waiting: t }
2811
+ end.sort_by { |row| row[:waiting] }.first(8)
2812
+ @daily = Array(@day&.fetch("daily"))
2813
+ cold_floor = now - COLD_AGENT_DAYS * 86_400
2814
+ @cold_agents = profiles ? profiles.all.map { |p| p.id.to_s }.sort.reject do |id|
2815
+ Array(by_agent[id]).any? { |s| (t = utc_time(s.updated_at)) && t >= cold_floor }
2816
+ end : []
2817
+ end
2818
+
2819
+ # Every record of a session/task store: one bulk read when the store offers
2820
+ # `all`, otherwise (an injected store with only each_id/find) one read per id.
2821
+ def every_record(store)
2822
+ store.respond_to?(:all) ? store.all : store.each_id.filter_map { |id| store.find(id) }
2823
+ end
2824
+
2825
+ # Sessions touched in the last HOME_WINDOW_DAYS. A store that keeps session
2826
+ # stats gives all of them (a few bytes each) and its session ids: stats of a
2827
+ # session that is gone are ignored, and the ids are the conversations total.
2828
+ # Any other store (an injected double) is read whole and filtered.
2829
+ # ponytail: reads every session's stats; a per-day index if the count of
2830
+ # sessions ever makes that slow.
2831
+ def window_sessions(now)
2114
2832
  store = insika[:session_store]
2115
2833
  return [] unless store
2116
2834
 
2117
- store.each_id.filter_map { |sid| store.find(sid) }
2835
+ floor = now - (HOME_WINDOW_DAYS * 86_400)
2836
+ in_window = ->(s) { (t = utc_time(s.updated_at)) && t >= floor }
2837
+ return every_record(store).select(&in_window) unless store.respond_to?(:all_stats)
2838
+
2839
+ ids = store.each_id.to_set
2840
+ @session_total = ids.size
2841
+ session_stats.select { |s| ids.include?(s.id) && in_window.call(s) }
2842
+ end
2843
+
2844
+ # A session's message count, whether the home holds the session or its stats.
2845
+ def message_count(session)
2846
+ session.respond_to?(:message_count) ? session.message_count : Array(session.messages).size
2847
+ end
2848
+
2849
+ def session_total
2850
+ return @session_total if @session_total
2851
+
2852
+ store = insika[:session_store]
2853
+ return 0 unless store
2854
+
2855
+ # a paging store counts by key (a Struct double answers #count too, hence #recent)
2856
+ store.respond_to?(:recent) && store.respond_to?(:count) ? store.count : every_record(store).size
2118
2857
  end
2119
2858
 
2120
2859
  def parse_time(str)
@@ -2161,7 +2900,7 @@ end
2161
2900
  def message_delta(sessions, now)
2162
2901
  today = now.to_date
2163
2902
  sum = ->(date) do
2164
- sessions.sum { |s| (t = utc_time(s.updated_at)) && t.to_date == date ? Array(s.messages).size : 0 }
2903
+ sessions.sum { |s| (t = utc_time(s.updated_at)) && t.to_date == date ? message_count(s) : 0 }
2165
2904
  end
2166
2905
  sum.call(today) - sum.call(today - 1)
2167
2906
  end
@@ -2171,8 +2910,9 @@ end
2171
2910
  # anything else would otherwise bucket by its own zone. `getutc`, not `utc`:
2172
2911
  # the latter mutates the receiver.
2173
2912
  def utc_time(value)
2174
- t = parse_time(value)
2175
- t&.getutc
2913
+ # one parse per stamp per request: the home reads tens of thousands of them,
2914
+ # several times each (window, activity, trend)
2915
+ (@utc_times ||= {}).fetch(value) { @utc_times[value] = parse_time(value)&.getutc }
2176
2916
  end
2177
2917
 
2178
2918
  # --- History -------------------------------------------------------------
@@ -2183,8 +2923,9 @@ end
2183
2923
  store = insika[:session_store]
2184
2924
  return [] unless store
2185
2925
 
2186
- store.each_id.filter_map { |sid| store.find(sid) }
2187
- .sort_by { |s| s.updated_at.to_s }.reverse.first(limit)
2926
+ return store.recent(limit) if store.respond_to?(:recent)
2927
+
2928
+ every_record(store).sort_by { |s| s.updated_at.to_s }.reverse.first(limit)
2188
2929
  end
2189
2930
 
2190
2931
  # Compact relative age ("just now", "9min", "3h", "2d") for a timestamp string.
@@ -2202,6 +2943,19 @@ end
2202
2943
  "#{hrs / 24}d"
2203
2944
  end
2204
2945
 
2946
+ # Same pattern as markdown.js TAG_TOKEN, on escaped text.
2947
+ TAG_TOKEN = %r{&lt;/?[a-z][\w-]*(?:\s+[\w-]+(?:=(?:&quot;.*?&quot;|&#39;.*?&#39;|[\w-]+))?)*\s*/?&gt;}i
2948
+
2949
+ # Escaped text with its markup tags (<carrossel/>, <vitrine formato="x">)
2950
+ # wrapped like markdown.js markTags does, for text not rendered as Markdown.
2951
+ # With `limit`: a one-line preview, cut without splitting a tag (a half tag
2952
+ # "<ve" would read as plain text).
2953
+ def mark_tags(text, limit = nil)
2954
+ s = text.to_s
2955
+ s = s.gsub(/\s+/, " ").strip[0, limit].sub(/<[^>]*\z/, "").rstrip if limit
2956
+ Rack::Utils.escape_html(s).gsub(TAG_TOKEN) { %(<span class="tag-token">#{_1}</span>) }
2957
+ end
2958
+
2205
2959
  def session_preview(session)
2206
2960
  last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
2207
2961
  # Structured content previews as JSON (message_content), never #inspect —
@@ -2213,14 +2967,19 @@ end
2213
2967
 
2214
2968
  # The agent config tab's groups, in sidebar order. The view renders the
2215
2969
  # same keys; a bogus ?cfg= falls back to the first group.
2216
- CONFIG_SECTIONS = %w[model guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
2970
+ CONFIG_SECTIONS = %w[model retrieval guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
2217
2971
 
2218
- SETTINGS_SECTIONS = %w[general models edge evals llm demo].freeze
2972
+ SETTINGS_SECTIONS = %w[general models edge burst agents evals llm demo].freeze
2219
2973
  def render_settings
2220
2974
  store = insika[:settings_store]
2221
2975
  @settings = store ? store.get : Insika::SettingsStore::DEFAULTS
2222
2976
  @providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
2223
2977
  @section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
2978
+ if @section == "agents"
2979
+ @agent = agent_defaults_profile
2980
+ @guardrails = @agent.guardrails || {}
2981
+ @agent_defaults_form = true
2982
+ end
2224
2983
  @persistence = insika[:config][:persistence].to_s
2225
2984
  view("settings")
2226
2985
  end
@@ -2314,11 +3073,190 @@ end
2314
3073
  # --- Chats -----------------------------------------------------
2315
3074
 
2316
3075
  def render_chats
2317
- @agent = presence(request.params["agent"])
2318
- @sessions = agent_sessions(recent_sessions(limit: 100), @agent)
3076
+ setup_chat_list(request.params)
2319
3077
  view("chats")
2320
3078
  end
2321
3079
 
3080
+ CHATS_PAGE = 100
3081
+
3082
+ # --- Live floor ---------------------------------------------
3083
+
3084
+ LIVE_CARDS = 24
3085
+
3086
+ # The live floor's baseline: the latest conversations, newest first.
3087
+ # ?agent= narrows the cards to one agent: newest first over the stats (a
3088
+ # few bytes each), then only the shown sessions are read whole.
3089
+ def render_live
3090
+ @agent = presence(request.params["agent"])
3091
+ store = insika[:session_store]
3092
+ @sessions = if @agent && store.respond_to?(:all_stats)
3093
+ session_stats.select { |s| session_agent(s) == @agent }
3094
+ .sort_by { |s| s.updated_at.to_s }.last(LIVE_CARDS).reverse
3095
+ .filter_map { |s| store.find(s.id) }
3096
+ else
3097
+ agent_sessions(recent_sessions(limit: LIVE_CARDS), @agent)
3098
+ end
3099
+ view("live")
3100
+ end
3101
+
3102
+ # The Chats master list, shared by the index and the session viewer. Agent
3103
+ # and id/customer search run over EVERY session's stats (a few bytes each), newest
3104
+ # first; only the rows shown are read whole. ?limit= grows the page.
3105
+ def setup_chat_list(params)
3106
+ @agent = presence(params["agent"])
3107
+ @query = presence(params["q"].to_s.strip)
3108
+ @status = CHAT_STATUSES.include?(params["status"]) ? params["status"] : nil
3109
+ @limit = [params["limit"].to_i, CHATS_PAGE].max
3110
+ store = insika[:session_store]
3111
+ return @sessions = [] unless store
3112
+
3113
+ all = store.respond_to?(:all_stats) ? session_stats : every_record(store)
3114
+ @chats_all = all.size
3115
+ # the agents column: one row per agent with conversations, latest first
3116
+ @chat_agents = all.group_by { |s| session_agent(s) }.except("")
3117
+ .map { |id, list| [id, list.size, list.map { |s| s.updated_at.to_s }.max] }
3118
+ .sort_by { |row| row.last }.reverse
3119
+ rows = agent_sessions(all, @agent)
3120
+ rows = rows.select { |s| chat_matches?(s, @query.downcase) } if @query
3121
+ @status_counts = CHAT_STATUSES.to_h { |st| [st, rows.count { |s| chat_status?(s, st) }] }
3122
+ rows = rows.select { |s| chat_status?(s, @status) } if @status
3123
+ rows = rows.sort_by { |s| s.updated_at.to_s }.reverse
3124
+ @chats_total = rows.size
3125
+ @sessions = rows.first(@limit).filter_map { |s| store.find(s.id) }
3126
+ @more = [CHATS_PAGE, rows.size - @limit].min
3127
+ end
3128
+
3129
+ # The Chats search: the session id, the customer key, name or phone.
3130
+ def chat_matches?(stat, query)
3131
+ vars = stat.vars.is_a?(Hash) ? stat.vars : {}
3132
+ [stat.id, vars["customer"], *vars.values_at(*Insika::SessionStore::CUSTOMER_LABELS)]
3133
+ .any? { |v| v.to_s.downcase.include?(query) }
3134
+ end
3135
+
3136
+ # One turn's tool calls as waterfall bars: each entry's start is its
3137
+ # stamp minus its duration; x/w are percent of the turn's span. Entries
3138
+ # without a parseable stamp are left out. -> [{ tool:, ok:, ms:, x:, w: }]
3139
+ # The skill a load_skill trace entry loaded, for the link to its page; nil for
3140
+ # any other tool. Args arrive as a Hash or as the JSON text the model sent.
3141
+ def loaded_skill_name(entry)
3142
+ return nil unless entry["tool"].to_s == "load_skill"
3143
+
3144
+ args = entry["args"]
3145
+ args = JSON.parse(args.to_s) rescue nil unless args.is_a?(Hash)
3146
+ name = args.is_a?(Hash) ? args["name"].to_s : ""
3147
+ name.empty? ? nil : name
3148
+ end
3149
+
3150
+ def trace_waterfall(entries)
3151
+ rows = entries.filter_map do |t|
3152
+ finish = utc_time(t["at"]) or next
3153
+ ms = t["ms"].to_f
3154
+ { tool: t["tool"], ok: t["ok"] && !t["gate"], ms: t["ms"], start: finish - (ms / 1000), finish: finish }
3155
+ end
3156
+ return [] if rows.empty?
3157
+
3158
+ first = rows.map { _1[:start] }.min
3159
+ span = [rows.map { _1[:finish] }.max - first, 0.001].max
3160
+ rows.map do |r|
3161
+ x = ((r[:start] - first) / span * 100).round(2)
3162
+ r.slice(:tool, :ok, :ms).merge(x: x, w: [((r[:finish] - r[:start]) / span * 100).round(2), 0.8].max.clamp(0, 100 - x))
3163
+ end
3164
+ end
3165
+
3166
+ # "Why did it say that?" — one entry per reply, keyed by the index of the
3167
+ # turn's LAST assistant message with text. Messages persisted with a task_id
3168
+ # split where it changes; older ones guess: a turn opens on a user message
3169
+ # that is not steered (a steered one joins the running turn). The context
3170
+ # entry matches by task_id (older ones: by time, it is written before the
3171
+ # turn's messages are persisted), tool traces by call id (the persisted
3172
+ # tool_calls carry it). -> { index => { context:, tools:, calls:, flags: } }
3173
+ def why_panels(messages, context_traces, tool_traces)
3174
+ by_call = tool_traces.to_h { [_1["call_id"].to_s, _1] }
3175
+ pending = context_traces.sort_by { _1["at"].to_s }
3176
+ turns = messages.each_index.slice_before do |i|
3177
+ task, prev = messages[i]["task_id"].to_s, i.positive? ? messages[i - 1]["task_id"].to_s : ""
3178
+ next task != prev unless task.empty? || prev.empty?
3179
+
3180
+ messages[i]["role"].to_s == "user" && !(i.positive? && messages[i - 1]["role"].to_s == "tool")
3181
+ end
3182
+ turns.each_with_object({}) do |idx, acc|
3183
+ msgs = messages.values_at(*idx)
3184
+ at = msgs.first["at"].to_s
3185
+ context = nil
3186
+ if (task_id = presence(msgs.first["task_id"].to_s))
3187
+ # exact: the turn's own trace (the last one, if the task suspended and resumed)
3188
+ context = context_traces.select { _1["task_id"].to_s == task_id }.max_by { _1["at"].to_s }
3189
+ pending.reject! { _1["at"].to_s <= at }
3190
+ else
3191
+ # older messages carry no task_id: the latest trace recorded before the turn was saved
3192
+ context = pending.shift while !at.empty? && pending.first && pending.first["at"].to_s <= at
3193
+ end
3194
+ reply = idx.reverse.find { |i| messages[i]["role"].to_s == "assistant" && !messages[i]["content"].to_s.empty? } or next
3195
+ calls = msgs.flat_map { |m| Array(m["tool_calls"]) }
3196
+ acc[reply] = { context: context, tools: by_call.values_at(*calls.map { _1["id"].to_s }).compact,
3197
+ calls: calls.map { _1["name"].to_s }.uniq,
3198
+ flags: msgs.select { _1["role"].to_s == "user" }.flat_map { Array(_1["guardrail_flags"]) } }
3199
+ end
3200
+ end
3201
+
3202
+ # A case prefilled from one reply (/studio/evals?session=&reply=): the
3203
+ # customer's messages up to it as `turns`, the transcript as the `reference`,
3204
+ # the tools its turn called as `tools_called`. Nothing is written here — the
3205
+ # operator adds the rubric and saves through the same POST as any case.
3206
+ def session_case_yaml(sid, reply)
3207
+ return nil if reply.negative?
3208
+
3209
+ session = insika[:session_store]&.find(sid) or return nil
3210
+ msgs = Array(session.messages).first(reply + 1)
3211
+ panel = why_panels(msgs, [], [])[reply] or return nil
3212
+ agent = presence(session_agent(session)) || "my-agent"
3213
+ said = msgs.select { %w[user assistant].include?(_1["role"].to_s) && !_1["content"].to_s.empty? }
3214
+ expect = { "tools_called" => panel[:calls], "must_not" => %w[pii_leak tool_error], "rubric" => "" }
3215
+ expect.delete("tools_called") if panel[:calls].empty?
3216
+ YAML.dump({ "id" => "#{agent}-#{sid[0, 8]}-#{reply}", "agent" => agent,
3217
+ "turns" => said.select { _1["role"] == "user" }.map { { "user" => message_content(_1["content"]).to_s } },
3218
+ "reference" => { "source" => "session #{sid}",
3219
+ # origin keeps an operator-typed reply from being judged as the model's
3220
+ "messages" => said.map do |m|
3221
+ { "role" => m["role"], "text" => message_content(m["content"]).to_s,
3222
+ "origin" => Insika::MessageOrigin.origin_of(m) }.compact
3223
+ end },
3224
+ "expect" => expect })
3225
+ end
3226
+
3227
+ CHAT_STATUSES = %w[active flagged].freeze
3228
+
3229
+ # The Chats status tabs: "active" = touched in the last 5 minutes (the
3230
+ # presence dot), "flagged" = a guardrail flagged one of its messages and
3231
+ # nobody resolved it yet.
3232
+ def chat_status?(stat, status)
3233
+ case status
3234
+ when "active" then session_fresh?(stat)
3235
+ when "flagged" then session_flagged?(stat)
3236
+ else true
3237
+ end
3238
+ end
3239
+
3240
+ def session_flagged?(session)
3241
+ if session.respond_to?(:flagged) then session.flagged.positive?
3242
+ else Array(session.respond_to?(:messages) ? session.messages : nil).any? { |m| m.is_a?(Hash) && Array(m["guardrail_flags"]).any? { Insika::SessionStore.open_flag?(_1) } }
3243
+ end
3244
+ end
3245
+
3246
+ # Conversations of the same customer key (stats only) — the session's
3247
+ # Customer card.
3248
+ def customer_chat_count(customer)
3249
+ store = insika[:session_store]
3250
+ return 1 if customer.to_s.empty? || !store.respond_to?(:all_stats)
3251
+
3252
+ [session_stats.count { |s| s.vars["customer"].to_s == customer }, 1].max
3253
+ end
3254
+
3255
+ def chats_path(**extra)
3256
+ query = Rack::Utils.build_query({ agent: @agent, q: @query, status: @status, **extra }.compact)
3257
+ query.empty? ? "/studio/chats" : "/studio/chats?#{query}"
3258
+ end
3259
+
2322
3260
  # --- Customers ----------------------------------
2323
3261
 
2324
3262
  # The index: every CUSTOMER memory cell, grouped by tenant. The caller
@@ -2328,15 +3266,7 @@ end
2328
3266
  # reads per conversation on every render).
2329
3267
  def render_customers
2330
3268
  mem = insika[:memory_store]
2331
- agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
2332
- @agent = presence(request.params["agent"])
2333
- @rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
2334
- { cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
2335
- end : []
2336
- # ?agent= narrows to one tenant — the memory scope is the agent (the
2337
- # agent-memory tab writes under tenant = agent id).
2338
- @rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
2339
- @by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
3269
+ setup_customer_rows(mem)
2340
3270
  view("customers")
2341
3271
  end
2342
3272
 
@@ -2348,28 +3278,150 @@ end
2348
3278
  @facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
2349
3279
  @notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
2350
3280
  @audit = insika[:memory_audit_store]&.for_cell(scope) || []
2351
- view("customer")
3281
+ # master column: the detail page IS the shell when visited directly;
3282
+ # only a frame request renders the pane alone.
3283
+ setup_customer_rows(mem)
3284
+ if turbo_frame?("customer-detail")
3285
+ render("customer", locals: { frame_only: true }, layout: false)
3286
+ else
3287
+ view("customer", locals: { frame_only: false })
3288
+ end
2352
3289
  end
2353
3290
 
2354
- def customer_path(scope)
3291
+ def customer_path(scope, agent: nil)
2355
3292
  # escape_path, not escape: the route decodes with unescape_path, which
2356
3293
  # does not turn '+' back into a space (escape's form-encoding would).
2357
- "/studio/customers/#{Rack::Utils.escape_path(scope)}"
3294
+ path = "/studio/customers/#{Rack::Utils.escape_path(scope)}"
3295
+ agent ? "#{path}?agent=#{Rack::Utils.escape(agent)}" : path
3296
+ end
3297
+
3298
+ # Shared helper for Customers index and detail: loads all customer cells,
3299
+ # counts facts per cell, filters by agent if ?agent= is present, and
3300
+ # groups by tenant. Sets @rows and @by_tenant.
3301
+ def setup_customer_rows(mem)
3302
+ agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
3303
+ @rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
3304
+ { cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
3305
+ end : []
3306
+ @agent = presence(request.params["agent"])
3307
+ @rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
3308
+ @by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
2358
3309
  end
2359
3310
 
2360
3311
  # Tasks & Approvals --------------------------------
2361
3312
 
3313
+ def render_models
3314
+ @period = request.params["period"]
3315
+ @agent = presence(request.params["agent"])
3316
+ @provider = presence(request.params["provider"])
3317
+ @model = presence(request.params["model"])
3318
+ # Dates count only for "Custom range" (or a bare ?from&to link): picking a fixed period wins.
3319
+ range = model_custom_range(request.params["from"], request.params["to"]) if [nil, "custom"].include?(@period)
3320
+ @range_from, @range_to = range&.map { |t| t.strftime("%Y-%m-%d") }
3321
+ args = { period: @period || "7d", agent: @agent, provider: @provider, model: @model }
3322
+ args.merge!(from: range[0], to: range[1] + 86_400) if range # `to` is inclusive: through the end of that day
3323
+ @report = insika[:model_metrics_store]&.report(**args)
3324
+ @period = @report ? @report["period"] : (%w[24h 7d 30d].include?(@period) ? @period : "7d")
3325
+ view("models")
3326
+ end
3327
+
3328
+ MODEL_RANGE_MAX_DAYS = 366
3329
+
3330
+ # [from, to] as UTC midnights from two YYYY-MM-DD fields; nil when either is
3331
+ # missing or malformed, reversed, or wider than a year (the fixed period applies).
3332
+ def model_custom_range(from, to)
3333
+ first, last = [from, to].map { |d| Time.strptime("#{d} UTC", "%Y-%m-%d %Z").utc }
3334
+ return nil if first > last || (last - first) / 86_400 > MODEL_RANGE_MAX_DAYS
3335
+
3336
+ [first, last]
3337
+ rescue ArgumentError, TypeError
3338
+ nil
3339
+ end
3340
+
3341
+ # The x-axis/table label follows the bucket size, custom ranges included.
3342
+ def model_interval_label(step)
3343
+ step = step.to_i
3344
+ return "Hourly" if step <= 3600
3345
+ return "6-hour intervals" if step <= 6 * 3600
3346
+ return "Daily" if step <= 86_400
3347
+
3348
+ "#{(step / 86_400.0).round}-day intervals"
3349
+ end
3350
+
3351
+ # Money for the home: cents above a dollar, 4 significant digits below it,
3352
+ # so a day of cheap turns never reads as $0.00.
3353
+ def usd(value)
3354
+ return "—" if value.nil?
3355
+
3356
+ value.abs >= 1 || value.zero? ? format("$%.2f", value) : format("$%.4g", value)
3357
+ end
3358
+
3359
+ def model_chart_value(value, unit)
3360
+ return "—" if value.nil?
3361
+
3362
+ case unit
3363
+ when "USD" then format("$%.6f", value)
3364
+ when "ms" then format("%.2f ms", value)
3365
+ else value.to_i.to_s
3366
+ end
3367
+ end
3368
+
3369
+ def model_chart_tick(value, unit)
3370
+ return "$0" if unit == "USD" && value.zero?
3371
+ return format("$%.4g", value) if unit == "USD"
3372
+ return format("%.1f s", value / 1000.0) if unit == "ms" && value >= 1000
3373
+ return format("%.3g ms", value) if unit == "ms"
3374
+
3375
+ format("%.3g", value)
3376
+ end
3377
+
3378
+ def model_chart_paths(points)
3379
+ points.slice_when { |left, right| left.nil? || right.nil? }.filter_map do |segment|
3380
+ values = segment.compact
3381
+ next if values.empty?
3382
+
3383
+ "M" + values.map { |x, y| format("%.2f %.2f", x, y) }.join(" L")
3384
+ end
3385
+ end
3386
+
2362
3387
  # Task list, most-recently-updated first. Empty-state if no store was injected.
2363
3388
  # `?agent=` narrows to one agent (the task's command payload stamps it).
3389
+ TASKS_PER_PAGE = 50
3390
+ # ponytail: the agent filter scans this many recent tasks (the payload holds
3391
+ # the agent, not the key); an index by agent if older matches ever matter.
3392
+ TASKS_AGENT_SCAN = 5_000
3393
+
3394
+ # One page of tasks, most recently updated first. Reads one page more than it
3395
+ # shows to know whether there is a next one, never the whole store.
2364
3396
  def render_tasks
2365
3397
  store = insika[:task_store]
2366
3398
  @agent = presence(request.params["agent"])
2367
- @tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
2368
- @tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
2369
- @tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
3399
+ @page = request.params["page"].to_i.clamp(1, 1_000_000)
3400
+ offset = (@page - 1) * TASKS_PER_PAGE
3401
+ rows =
3402
+ if store.nil? then []
3403
+ elsif @agent
3404
+ recent_tasks(store, TASKS_AGENT_SCAN, 0).select { |t| task_agent(t) == @agent }
3405
+ .drop(offset).first(TASKS_PER_PAGE + 1)
3406
+ else
3407
+ recent_tasks(store, TASKS_PER_PAGE + 1, offset)
3408
+ end
3409
+ @more = rows.size > TASKS_PER_PAGE
3410
+ @tasks = rows.first(TASKS_PER_PAGE)
2370
3411
  view("tasks")
2371
3412
  end
2372
3413
 
3414
+ def recent_tasks(store, limit, offset)
3415
+ return store.recent(limit, offset: offset) if store.respond_to?(:recent)
3416
+
3417
+ every_record(store).sort_by { |t| t.updated_at.to_s }.reverse.drop(offset).first(limit)
3418
+ end
3419
+
3420
+ def tasks_page_path(page)
3421
+ query = { "agent" => @agent, "page" => (page if page > 1) }.compact
3422
+ query.empty? ? "/studio/tasks" : "/studio/tasks?#{Rack::Utils.build_query(query)}"
3423
+ end
3424
+
2373
3425
  def task_agent(task)
2374
3426
  command = task.command
2375
3427
  command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
@@ -2378,8 +3430,9 @@ end
2378
3430
  # Task detail: @task is set by the route. Adds the open approvals for this task
2379
3431
  # and its latest checkpoint (both degrade to empty when the store is absent).
2380
3432
  def render_task_detail(id)
2381
- @pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id) : []
3433
+ @pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id, kind: Insika::PendingActionStore::OPERATOR) : []
2382
3434
  @checkpoint = insika[:checkpoint_store]&.latest(id)
3435
+ @llm_trace = insika[:llm_trace_store]&.for_task(id) || { "entries" => [], "truncated" => false }
2383
3436
  view("task")
2384
3437
  end
2385
3438
 
@@ -2390,7 +3443,7 @@ end
2390
3443
  pstore = insika[:pending_action_store]
2391
3444
  tstore = insika[:task_store]
2392
3445
  @agent = presence(request.params["agent"])
2393
- @approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
3446
+ @approvals = pstore ? pstore.all_open(kind: Insika::PendingActionStore::OPERATOR).map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
2394
3447
  if @agent
2395
3448
  @approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
2396
3449
  end
@@ -2412,6 +3465,8 @@ def render_evals
2412
3465
  wanted = presence(request.params["id"])
2413
3466
  @case = wanted && @cases.find { |g| g.id == wanted }
2414
3467
  @case_yaml = @case ? golden_yaml(@case) : nil
3468
+ from = presence(request.params["session"])
3469
+ @case_yaml ||= session_case_yaml(utf8(from), request.params["reply"].to_i) if from
2415
3470
  view("evals")
2416
3471
  end
2417
3472
 
@@ -2433,19 +3488,43 @@ end
2433
3488
 
2434
3489
  # Refinement -----------------------------
2435
3490
 
2436
- # The agent's latest failure report + its run history. Empty-state when no store
2437
- # was injected or the agent has never been run — the page is the invitation to
2438
- # run it, so there is nothing to hide behind a nil.
3491
+ # The index: master pane lists the scoped agent's run history; the detail
3492
+ # pane defaults to the run that most needs a look — one awaiting a human
3493
+ # answer if there is one (at most one is possible: gating requires a
3494
+ # `completed` run and there is one lifecycle per record), else the most
3495
+ # recent terminal run. Empty-state when no store was injected or the
3496
+ # agent has never been run — the page is the invitation to run it, so
3497
+ # there is nothing to hide behind a nil.
2439
3498
  def render_refinement
3499
+ refinement_rows(request.params["agent"])
3500
+ @selected = @runs.find(&:awaiting_approval?) || @runs.find(&:terminal?)
3501
+ view("refinement")
3502
+ end
3503
+
3504
+ # GET /studio/refinement/:id — one run, whatever its status, with the
3505
+ # master pane scoped to ITS agent (a deep link may land on a run that
3506
+ # isn't the current default selection).
3507
+ def render_refinement_detail(id)
3508
+ @selected = insika[:refinement_store]&.find(id)
3509
+ next_404 unless @selected
3510
+
3511
+ refinement_rows(@selected.agent_id)
3512
+ if turbo_frame?("refinement-detail")
3513
+ render("refinement_detail", locals: { frame_only: true }, layout: false)
3514
+ else
3515
+ view("refinement_detail", locals: { frame_only: false })
3516
+ end
3517
+ end
3518
+
3519
+ # Shared read for both Refinement routes: every agent id (the Run form's
3520
+ # target and the scope filter's options), the ?agent= scope (falling back
3521
+ # to the first agent so there is always something to show), and that
3522
+ # agent's run history, most recent first.
3523
+ def refinement_rows(requested_agent)
2440
3524
  @agents = insika[:profile_source].ids.sort
2441
- @agent = presence(request.params["agent"]) || @agents.first
3525
+ @agent = presence(requested_agent) || @agents.first
2442
3526
  store = insika[:refinement_store]
2443
3527
  @runs = @agent && store ? store.for_agent(@agent, limit: 10) : []
2444
- @run = @runs.find(&:terminal?)
2445
- # The one run that owes this agent a human answer. At most one is possible:
2446
- # gating requires a `completed` run and there is one lifecycle per record.
2447
- @proposal = @runs.find(&:awaiting_approval?)
2448
- view("refinement")
2449
3528
  end
2450
3529
 
2451
3530
  # --- Parity -------------------------------------------------
@@ -2587,6 +3666,9 @@ end
2587
3666
  p = request.params["period"].to_i
2588
3667
  @period = p.positive? ? p : 30
2589
3668
  @agent = presence(request.params["agent"])
3669
+ # ?drop=<stage>&tenant=: the "where conversations stop" drill of ONE card
3670
+ @drop = presence(request.params["drop"])
3671
+ @drop_tenant = presence(request.params["tenant"])
2590
3672
  profiles = profiles.select { |p| p.id == @agent } if @agent
2591
3673
  @stores = profiles.filter_map do |p|
2592
3674
  decl = Insika::FunnelDeclaration.parse(p.funnel)
@@ -2625,6 +3707,32 @@ end
2625
3707
  # a link, never a copy: the excerpt is read from the session at request
2626
3708
  # time); the mutations dispatch :resolve_proposal on the bus.
2627
3709
  def render_facts
3710
+ facts_queues
3711
+ # The landing selection: the first pending item (the operator's next
3712
+ # thing to review) — falling back to stale, then a resolved one, so the
3713
+ # pane is never blank while ANY proposal exists. `.empty` only when the
3714
+ # whole queue (pending + stale + recent) is.
3715
+ @selected = @pending.first || @stale.first || @recent.first
3716
+ view("facts")
3717
+ end
3718
+
3719
+ # GET /studio/facts/:id — a specific proposal, whatever its status. Reads
3720
+ # the store directly (a resolved fact past the `resolved(limit: 20)` window
3721
+ # still opens by id — the truncated lists are for the queue, not a lookup).
3722
+ def render_fact_detail(id)
3723
+ facts_queues
3724
+ @selected = insika[:proposal_store]&.find(id)
3725
+ next_404 unless @selected
3726
+ if turbo_frame?("fact-detail")
3727
+ render("fact_detail", layout: false)
3728
+ else
3729
+ view("facts")
3730
+ end
3731
+ end
3732
+
3733
+ # Shared read for both Facts routes: the three queues (pending/stale/
3734
+ # recent), the `?agent=`/`?store=` filter, and the scope picklist.
3735
+ def facts_queues
2628
3736
  store = insika[:proposal_store]
2629
3737
  @pending = store ? store.pending(limit: 100) : []
2630
3738
  @stale = store ? store.stale(limit: 50) : []
@@ -2644,7 +3752,6 @@ end
2644
3752
  @stores = (insika[:profile_source].ids.sort +
2645
3753
  (@pending + @stale + @recent).map(&:scope)).uniq.sort
2646
3754
  @sessions = insika[:session_store]
2647
- view("facts")
2648
3755
  end
2649
3756
 
2650
3757
  # A proposal matches the filter as the memory scope it carries, or as the
@@ -2656,6 +3763,19 @@ end
2656
3763
  session && session_agent(session) == filter
2657
3764
  end
2658
3765
 
3766
+ # Where a resolve POST lands: the next pending item in the SAME filtered
3767
+ # scope the operator was working, so approve/reject/dismiss feeds straight
3768
+ # into the next thing to review instead of dumping them back on a list.
3769
+ # No pending left (in that scope) -> the plain facts list (stale/recent,
3770
+ # or the empty state).
3771
+ def next_fact_path(filter)
3772
+ store = insika[:proposal_store]
3773
+ pending = store ? store.pending(limit: 100) : []
3774
+ pending = pending.select { |p| scope_matches?(p, filter) } if filter
3775
+ base = pending.first ? "/studio/facts/#{Rack::Utils.escape(pending.first.id)}" : "/studio/facts"
3776
+ filter ? "#{base}?store=#{Rack::Utils.escape(filter)}" : base
3777
+ end
3778
+
2659
3779
  # the Harvest page — the human gate on mined skills. Reads
2660
3780
  # HarvestStore/SessionStore directly (the evidence excerpt is read from the
2661
3781
  # session at request time — D7: ids and indexes only are stored); every
@@ -2683,9 +3803,28 @@ end
2683
3803
  @sessions = insika[:session_store]
2684
3804
  @negative = insika[:negative_list]
2685
3805
  @filter = presence(request.params["agent"])
3806
+ @idle_reason = harvest_idle_reason(@agent)
2686
3807
  view("harvest")
2687
3808
  end
2688
3809
 
3810
+ # Why the automatic loop elects none of this agent's conversations — the
3811
+ # engine skips them silently (HarvestEngine#harvest_declared?), so the page
3812
+ # says it. nil when the agent is minable.
3813
+ def harvest_idle_reason(agent_id)
3814
+ profile = agent_id && insika[:profile_source].fetch(agent_id)
3815
+ return nil unless profile
3816
+
3817
+ # The loop reads profiles with the platform defaults applied (an agent
3818
+ # with no harvest of its own inherits Settings › Agent defaults).
3819
+ profile = Insika::AgentDefaults.apply_safely(profile, platform_agent_defaults)
3820
+ return :off unless Insika::Coercion.truthy?((profile.harvest || {})["enabled"])
3821
+
3822
+ grounding = Insika::Grounding.parse(profile.grounding)
3823
+ grounding&.matcher&.sku? ? nil : :no_matcher
3824
+ rescue StandardError
3825
+ :no_matcher # a matcher that does not parse is no matcher
3826
+ end
3827
+
2689
3828
  # --- Knowledge index -------------------------------------------------
2690
3829
 
2691
3830
  # Master data for the Knowledge drill-down: every concept of ONE agent
@@ -2693,7 +3832,7 @@ end
2693
3832
  # occurrences/updated_at). A record that fails to parse (corrupted by a
2694
3833
  # hand edit) still shows by name, blank elsewhere, rather than vanishing.
2695
3834
  def load_knowledge_master(agent:, tenant: nil)
2696
- @status ||= "all"
3835
+ @status ||= presence(request.params["status"]) || "all"
2697
3836
  store = insika[:knowledge_store]
2698
3837
  names = agent && store ? store.names(agent, tenant: tenant) : []
2699
3838
  concepts = names.filter_map do |n|
@@ -2747,9 +3886,10 @@ end
2747
3886
 
2748
3887
  # The redirect/link target after a knowledge write or restore — back to
2749
3888
  # the concept's own detail pane when a name is known, else the index.
2750
- def knowledge_path(agent, name, tenant)
3889
+ def knowledge_path(agent, name, tenant, status: nil)
2751
3890
  q = "agent=#{Rack::Utils.escape(agent.to_s)}"
2752
3891
  q += "&tenant=#{Rack::Utils.escape(tenant)}" if tenant
3892
+ q += "&status=#{Rack::Utils.escape(status)}" if status
2753
3893
  name ? "/studio/knowledge/#{Rack::Utils.escape(name)}?#{q}" : "/studio/knowledge?#{q}"
2754
3894
  end
2755
3895
 
@@ -2816,10 +3956,22 @@ end
2816
3956
  store = insika[:followup_store]
2817
3957
  return {} unless store
2818
3958
 
2819
- from = (Time.now.utc - 30 * 86_400).iso8601
2820
- fired = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
2821
- agent: agent_id)
2822
- .select { |r| r.status == "fired" && r.at.to_s >= from }
3959
+ fired_all = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
3960
+ agent: agent_id)
3961
+ .select { |r| r.status == "fired" }
3962
+ return {} if fired_all.empty?
3963
+
3964
+ # The window is 30 days of FIRES (fired_at, never the scheduled `at` —
3965
+ # followup_store.rb's rule, the same one the frequency ceiling follows),
3966
+ # anchored on the most recent fire across ALL arms of this agent — not
3967
+ # per arm — rather than the wall clock. A wall-clock anchor would blank
3968
+ # the card between visits. One shared window (not each arm's own last
3969
+ # fire) is also the right call for an A/B readout: both arms are then
3970
+ # measured over the same calendar span, so their percentages are
3971
+ # actually comparable.
3972
+ latest = fired_all.filter_map { |r| parse_time(r.fired_at || r.at) }.max
3973
+ from = (latest - 30 * 86_400).iso8601
3974
+ fired = fired_all.select { |r| (r.fired_at || r.at).to_s >= from }
2823
3975
  fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
2824
3976
  sessions = records.filter_map(&:session_id)
2825
3977
  acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
@@ -2835,16 +3987,27 @@ end
2835
3987
  return 0 unless outcomes
2836
3988
 
2837
3989
  records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
2838
- primary = primary_stage_for(agent_id)
2839
- return records.count { |r| r.outcome == primary } if primary
3990
+ return records.count { |r| reached_primary?(agent_id, r) } if primary_stage_for(agent_id)
2840
3991
 
2841
3992
  records.group_by(&:session_id).size
2842
3993
  end
2843
3994
 
2844
3995
  def primary_stage_for(agent_id)
3996
+ funnel_decl_for(agent_id)&.primary
3997
+ end
3998
+
3999
+ # An outcome record is a kind; the primary is a stage. advance_on maps one to the other.
4000
+ def reached_primary?(agent_id, record)
4001
+ decl = funnel_decl_for(agent_id)
4002
+ decl && decl.advance_on[record.outcome].to_s == decl.primary.to_s
4003
+ end
4004
+
4005
+ def funnel_decl_for(agent_id)
4006
+ @funnel_decls ||= {}
4007
+ return @funnel_decls[agent_id] if @funnel_decls.key?(agent_id)
4008
+
2845
4009
  profile = insika[:profile_source]&.fetch(agent_id)
2846
- decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2847
- decl && decl.primary
4010
+ @funnel_decls[agent_id] = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2848
4011
  end
2849
4012
 
2850
4013
  # The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
@@ -2898,6 +4061,73 @@ end
2898
4061
  .each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
2899
4062
  end
2900
4063
 
4064
+ # The loss between each pair of consecutive declared stages, in order; the
4065
+ # biggest positive one is marked (ties: the earliest stage).
4066
+ # -> [{ from:, to:, lost:, of:, biggest: }]
4067
+ def funnel_drops(decl, counts)
4068
+ rows = decl.stages.each_cons(2).map do |a, b|
4069
+ # stages are counted apart, so a later one can outgrow the earlier: no negative loss
4070
+ { from: a, to: b, lost: [counts[a].to_i - counts[b].to_i, 0].max, of: counts[a].to_i, biggest: false }
4071
+ end
4072
+ top = rows.max_by { |row| row[:lost] }
4073
+ top[:biggest] = true if top && top[:lost].positive?
4074
+ rows
4075
+ end
4076
+
4077
+ def funnel_drop_path(agent, tenant, stage)
4078
+ query = Rack::Utils.build_query({ agent: agent, period: @period, drop: stage, tenant: tenant }.compact)
4079
+ "/studio/funnel?#{query}"
4080
+ end
4081
+
4082
+ # The last thing that happened in a conversation, most specific first.
4083
+ DROP_REASONS = {
4084
+ "tool_failed" => "Last tool call failed",
4085
+ "flagged" => "A guardrail flagged a message",
4086
+ "waiting" => "Customer waiting on a reply",
4087
+ "quiet" => "Customer went quiet after the agent's reply",
4088
+ "unknown" => "No message to read",
4089
+ "gone" => "Conversation no longer stored"
4090
+ }.freeze
4091
+ DROP_LIST_CAP = 10
4092
+ # Each classified session costs two reads (session + tool trace): only the
4093
+ # newest ones are classified; the view says how many were left out.
4094
+ DROP_CLASSIFY_CAP = 200
4095
+
4096
+ # The conversations that reached `stage` but not the next one in the
4097
+ # period: each session's furthest stage over its outcome records (the
4098
+ # fold's own advance_on mapping), kept when it is exactly `stage`.
4099
+ # -> [[reason, [session_id]]] in DROP_REASONS order, newest first, over at
4100
+ # most DROP_CLASSIFY_CAP sessions (@drop_total = all of them) | nil (no such drop)
4101
+ def funnel_drop_sessions(decl, tenant, agent, stage, period)
4102
+ index = decl.index_of(stage)
4103
+ outcomes = insika[:outcome_store]
4104
+ return nil unless index && index < decl.stages.length - 1 && outcomes
4105
+
4106
+ furthest = Hash.new(-1)
4107
+ latest = {}
4108
+ outcomes.for_pair(tenant: tenant, agent: agent, since_date: (Date.today - period).iso8601).each do |r|
4109
+ i = decl.index_of(decl.advance_on[r.outcome].to_s)
4110
+ next unless i && !r.session_id.to_s.empty?
4111
+
4112
+ furthest[r.session_id] = [furthest[r.session_id], i].max
4113
+ latest[r.session_id] = [latest[r.session_id].to_s, r.at.to_s].max
4114
+ end
4115
+ stuck = furthest.select { |_, i| i == index }.keys.sort_by { |sid| latest[sid] }.reverse
4116
+ @drop_total = stuck.size
4117
+ stuck.first(DROP_CLASSIFY_CAP)
4118
+ .group_by { |sid| drop_reason(sid) }
4119
+ .sort_by { |reason, _| DROP_REASONS.keys.index(reason) }
4120
+ end
4121
+
4122
+ def drop_reason(sid)
4123
+ session = insika[:session_store]&.find(sid)
4124
+ return "gone" unless session
4125
+ return "tool_failed" if Array(insika[:tool_trace_store]&.for_session(sid)).last&.[]("ok") == false
4126
+ return "flagged" if session_flagged?(session)
4127
+
4128
+ { "user" => "waiting", "assistant" => "quiet" }.fetch(last_role(session), "unknown")
4129
+ end
4130
+
2901
4131
  # BudgetLedger's current counters (D6): "tokens today / this month". nil
2902
4132
  # store -> the view renders 0 (the empty state).
2903
4133
  def funnel_spend(store, tenant, agent)
@@ -2935,6 +4165,24 @@ end
2935
4165
  "last #{window['last_sessions'] || Insika::Refinement::EvidenceCollector::DEFAULT_WINDOW} conversation(s)"
2936
4166
  end
2937
4167
 
4168
+ # How long a run took (finished_at - started_at), or how long an open one
4169
+ # has been running so far (against now) — compact, like time_ago's units.
4170
+ def refinement_elapsed(run)
4171
+ start = parse_time(run.started_at)
4172
+ return nil unless start
4173
+
4174
+ finish = run.finished_at ? parse_time(run.finished_at) : Time.now
4175
+ return nil unless finish
4176
+
4177
+ secs = (finish - start).to_i
4178
+ return "#{secs}s" if secs < 60
4179
+
4180
+ mins = secs / 60
4181
+ return "#{mins}min" if mins < 60
4182
+
4183
+ "#{mins / 60}h"
4184
+ end
4185
+
2938
4186
  # An operator control (pause/resume/cancel/approve): audits the ATTEMPT to the
2939
4187
  # shared EventStream BEFORE dispatching — accountability survives a Command
2940
4188
  # failure (parity with server/admin's `act`) — then dispatches via the bus with
@@ -2962,15 +4210,18 @@ end
2962
4210
  # is the operator). Mirrors server/admin's operator_of default.
2963
4211
  def operator_label = "studio"
2964
4212
 
2965
- # Semantic status class for the .pill CSS (completed=ok, running=run,
2966
- # waiting/queued/paused=warn, failed/cancelled=err). Parity with admin's
2967
- # status_pill mapping; used by the tasks/approvals views.
4213
+ # Semantic status class, shared by the `.pill` and `.status` CSS: one
4214
+ # vocabulary (ok / warn / err / info / neutral, plus the pill-only `run`)
4215
+ # for every status string the console renders — task lifecycle, follow-up
4216
+ # arms, approval decisions. `neutral` is the "nothing happened" tone: a
4217
+ # cancelled or dismissed item is not a failure.
2968
4218
  def status_class(status)
2969
4219
  case status.to_s
2970
- when "completed" then "ok"
4220
+ when "completed", "fired", "approved", "ok", "applied" then "ok"
2971
4221
  when "running" then "run"
2972
- when "waiting", "queued", "paused" then "warn"
2973
- when "failed", "cancelled" then "err"
4222
+ when "pending", "waiting", "queued", "paused", "awaiting_approval" then "warn"
4223
+ when "blocked", "failed", "rejected" then "err"
4224
+ when "cancelled", "dismissed", "no_findings" then "neutral"
2974
4225
  else "info"
2975
4226
  end
2976
4227
  end
@@ -2993,13 +4244,33 @@ end
2993
4244
  dispatch(:send_message, { agent: agent, session_id: session_id, message: message }, tenant: agent)
2994
4245
  end
2995
4246
 
2996
- # Creates a new session via the bus (create_session generates the id) and returns
4247
+ # Creates a new session via the bus (with `id`, or create_session generates one) and returns
2997
4248
  # the id. `model`/`provider` (optional) become the per-chat pin: CreateSession
2998
4249
  # stashes them in the reserved `vars["__llm__"]` slot the ModelResolver reads as
2999
4250
  # the highest-precedence layer (Chat > Agent > platform default). Only non-blank
3000
4251
  # values are sent, so an empty override leaves the session unpinned.
3001
- def create_session(model: nil, provider: nil, thinking: nil)
4252
+ # The message of the turn still running in `session_id`, nil when none: it is
4253
+ # only saved with the turn, at its end, so a viewer who comes back mid-turn
4254
+ # (the flash already spent) would not see what was asked. Only the most
4255
+ # recently updated tasks are read — a running turn is always among them.
4256
+ def running_message(session_id)
4257
+ return unless session_id
4258
+
4259
+ task = insika[:task_store]&.recent(20)&.find do |t|
4260
+ t.session_id == session_id && %i[queued running waiting paused].include?(t.status)
4261
+ end
4262
+ task&.command&.dig("payload", "message")
4263
+ end
4264
+
4265
+ # The playground's model picker -> { model:, provider: } for create_session.
4266
+ def pinned_model(r)
4267
+ model, provider = picked_model(r)
4268
+ { model: model, provider: provider }
4269
+ end
4270
+
4271
+ def create_session(id: nil, model: nil, provider: nil, thinking: nil)
3002
4272
  payload = { vars: { "canal" => "studio" } }
4273
+ payload[:id] = id if id
3003
4274
  payload[:model] = model if model
3004
4275
  payload[:provider] = provider if provider
3005
4276
  payload[:thinking] = thinking if thinking
@@ -3045,6 +4316,16 @@ end
3045
4316
  "avatar-h#{id.to_s.bytes.sum % 10}"
3046
4317
  end
3047
4318
 
4319
+ # Same idea for `.identity`'s 28px circle, but over the NINE themed --av-N
4320
+ # ramps only (no h0 — the brand accent is reserved for the product's own
4321
+ # marks, not for an arbitrary row). Deterministic: the same id always
4322
+ # yields the same hue, so a list keeps its colours across renders.
4323
+ def avatar_hue(id) = (id.to_s.bytes.sum % 9) + 1
4324
+
4325
+ # The readable head of a uuid. Callers put the FULL value in `title` and
4326
+ # `data-copy` so nothing is lost by the truncation.
4327
+ def short_id(uuid) = uuid.to_s[0, 8]
4328
+
3048
4329
  # Where a prompt POST lands back: the same file open in the drill, scrolled
3049
4330
  # to the prompts section. No file (a delete, say) → the agent page itself.
3050
4331
  def prompt_edit_path(id, file)
@@ -3053,11 +4334,14 @@ end
3053
4334
  end
3054
4335
 
3055
4336
  # Serves a versioned asset from dist. `File.basename` kills path traversal; only
3056
- # files that exist in the dist dir are served.
3057
- def serve_asset(name)
4337
+ # files that exist in the dist dir are served. `dir` is the one allowed
4338
+ # sub-directory ("fonts"), passed by the route as a literal — never by the
4339
+ # client — so the traversal guard still holds.
4340
+ def serve_asset(name, dir = nil)
3058
4341
  base = File.basename(name)
3059
- path = File.join(ASSETS_DIR, base)
3060
- unless File.file?(path) && File.fnmatch(File.join(ASSETS_DIR, "*"), path)
4342
+ root = dir ? File.join(ASSETS_DIR, dir) : ASSETS_DIR
4343
+ path = File.join(root, base)
4344
+ unless File.file?(path) && File.fnmatch(File.join(root, "*"), path)
3061
4345
  response.status = 404
3062
4346
  return "not found"
3063
4347
  end
@@ -3068,7 +4352,9 @@ end
3068
4352
  # Assets are tiny and same-origin, so the revalidation cost is negligible —
3069
4353
  # correctness over caching for an actively-edited admin UI.
3070
4354
  response["cache-control"] = "no-cache"
3071
- File.read(path)
4355
+ # Webfonts are binary: File.read would tag the bytes UTF-8 and any later
4356
+ # String operation on the body would raise on the first invalid sequence.
4357
+ base.end_with?(".woff2") ? File.binread(path) : File.read(path)
3072
4358
  end
3073
4359
 
3074
4360
  # Cache-busting URL for a dist asset. Dist files are served under a STABLE
@@ -3084,6 +4370,12 @@ end
3084
4370
  "/studio/assets/dist/#{base}?v=#{v}"
3085
4371
  end
3086
4372
 
4373
+ # URL of an embedded webfont. No `?v=` cache-bust on purpose: the file name
4374
+ # already names the family and weight (a different font is a different
4375
+ # file), and the preload link in layout.erb has to match the @font-face
4376
+ # `src:` in application.css BYTE FOR BYTE or the browser fetches twice.
4377
+ def font_path(file) = "/studio/assets/dist/fonts/#{file}"
4378
+
3087
4379
  def presence(str) = Insika::Coercion.presence(str)
3088
4380
 
3089
4381
  # Masked-secret sentinel (to pre-fill credential fields in the