insika 0.2.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -53,6 +53,11 @@ module Studio
53
53
  # Strict CSP: no `unsafe-inline`. Everything comes from the same-origin bundle in
54
54
  # /studio/assets/dist. `connect-src 'self'` covers the playground's EventSource
55
55
  # (SSE from /studio/events, same origin). `img-src data:` covers inline SVG/icons.
56
+ # `frame-src 'self'` (explicit, not left to the `default-src 'none'` fallback):
57
+ # the artifact preview page frames /studio/artifacts/:id/content — same-origin,
58
+ # but frame-src does NOT inherit from default-src, so without this line the
59
+ # browser blocked its own iframe (found live: "Framing '...' violates ...
60
+ # default-src 'none' ... frame-src was not explicitly set").
56
61
  plugin :content_security_policy do |csp|
57
62
  csp.default_src :none
58
63
  csp.script_src :self
@@ -63,6 +68,7 @@ module Studio
63
68
  csp.form_action :self
64
69
  csp.base_uri :none
65
70
  csp.frame_ancestors :none
71
+ csp.frame_src :self
66
72
  end
67
73
 
68
74
  class << self
@@ -81,10 +87,20 @@ module Studio
81
87
  def configure(command_bus:, profile_source:, event_stream:, config:,
82
88
  agent_file_store: nil, skill_store: nil, skill_catalog: nil,
83
89
  tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
90
+ memory_audit_store: nil,
84
91
  settings_store: nil, llm_provider_store: nil, mcp_store: nil,
85
92
  system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
93
+ cache_series_store: nil,
86
94
  task_store: nil, checkpoint_store: nil, pending_action_store: nil,
87
- refinement_store: nil, golden_store: nil, session_secret: nil)
95
+ refinement_store: nil, golden_store: nil, session_secret: nil,
96
+ outcome_store: nil, shadow_pair_store: nil, parity_criterion: nil,
97
+ funnel_store: nil, budget_ledger: nil,
98
+ followup_store: nil, contact_store: nil,
99
+ proposal_store: nil,
100
+ harvest_store: nil, harvest_criterion: nil, negative_list: nil,
101
+ channel_registry: nil,
102
+ artifact_store: nil, artifact_signing: nil,
103
+ knowledge_store: nil)
88
104
  @insika = {
89
105
  command_bus: command_bus, profile_source: profile_source,
90
106
  event_stream: event_stream, config: config,
@@ -94,6 +110,9 @@ module Studio
94
110
  # shows the data-tools in the matrix; the store feeds the authoring page.
95
111
  tool_store: tool_store,
96
112
  memory_store: memory_store, session_store: session_store,
113
+ # the Customers drill renders the audit lines (digests +
114
+ # counts, never content). nil = the audit section renders empty.
115
+ memory_audit_store: memory_audit_store,
97
116
  # runtime config (settings/LLM/MCP) + global system
98
117
  # files + conversations index. All optional (empty-state if nil).
99
118
  settings_store: settings_store, llm_provider_store: llm_provider_store,
@@ -104,6 +123,10 @@ module Studio
104
123
  # per-session context breakdown: tokens by category +
105
124
  # budget per turn, on the same viewer. Counts only, no content.
106
125
  context_trace_store: context_trace_store,
126
+ # per-AGENT cache-hit series (percentages + counts, no
127
+ # content) — the cache tab on the agent detail. nil = tab renders
128
+ # "No turns recorded.".
129
+ cache_series_store: cache_series_store,
107
130
  # operate: tasks + human-in-the-loop approvals. Reads the
108
131
  # task/checkpoint/pending stores to render; controls (pause/resume/
109
132
  # cancel/approve) dispatch on the bus — parity with server/admin.
@@ -113,15 +136,48 @@ module Studio
113
136
  # Read-only here; the "Run" button dispatches :run_refinement on the bus.
114
137
  refinement_store: refinement_store,
115
138
  # eval cases: read to render; writes go through:write_golden.
116
- golden_store: golden_store
139
+ golden_store: golden_store,
140
+ # WS7: business outcomes — the agents grid's last-outcome pill; the
141
+ # agent detail shows the per-day series. Reads only; recording goes
142
+ # through POST /v1/outcomes.
143
+ outcome_store: outcome_store,
144
+ # the Funnel page reads the fold's cells and the
145
+ # BudgetLedger's current counters (D6). nil = the page renders its
146
+ # empty state.
147
+ funnel_store: funnel_store, budget_ledger: budget_ledger,
148
+ # the shadow pair store (read to render /studio/parity), the
149
+ # frozen criterion (folded by Verdict), and the channel registry (the
150
+ # nav row only exists when a shadow channel is registered — the page
151
+ # is not an invitation, unlike Refinement's).
152
+ shadow_pair_store: shadow_pair_store, parity_criterion: parity_criterion,
153
+ channel_registry: channel_registry,
154
+ # the Follow-ups page READS the stores directly;
155
+ # its only mutations — cancel a pending record, force-revoke a contact
156
+ # — dispatch :cancel_followup / :revoke_contact on the bus. nil = the
157
+ # page renders its empty state.
158
+ followup_store: followup_store, contact_store: contact_store,
159
+ # the Facts (wiki) page reads the proposal store directly
160
+ # (approvals/rejections dispatch :resolve_proposal on the bus).
161
+ # nil = the page renders its empty state.
162
+ proposal_store: proposal_store,
163
+ # the Harvest page reads the harvest store directly
164
+ # (the pending/awaiting/promoted lists); its mutations — mine, gate,
165
+ # promote, reject, rollback — dispatch bus commands. The criterion
166
+ # (boot-loaded, with its sha) and the negative list render the two
167
+ # pre-registered artifacts. nil = the page renders its empty state.
168
+ harvest_store: harvest_store, harvest_criterion: harvest_criterion,
169
+ negative_list: negative_list,
170
+ # the Artifacts tab reads the report store directly; its
171
+ # only mutation (delete) dispatches :delete_artifact on the bus.
172
+ # artifact_signing is a { key:, ttl:, base_url: } Hash when a signing
173
+ # key is configured (nil = no signed surface). nil stores = the
174
+ # pages render their empty states.
175
+ artifact_store: artifact_store, artifact_signing: artifact_signing,
176
+ # the Knowledge page reads the store directly (list/edit/
177
+ # conflict filter); its mutations (write/delete/restore) dispatch
178
+ # bus commands. nil = the page renders its empty state.
179
+ knowledge_store: knowledge_store
117
180
  }.freeze
118
- # "restart recommended" flag — in memory, PER PROCESS. A
119
- # config change that the runtime only re-reads at boot (e.g.: MCP instances are
120
- # wired at startup) lights the flag; restarting the process clears it
121
- # naturally (new process = `configure` runs again = flag reset).
122
- # Deliberately not put in the session: the session survives the restart (the secret
123
- # derives from the token) and the flag would stay stuck on.
124
- @restart_needed = false
125
181
  secret = session_secret || derive_secret(config[:admin_token])
126
182
  plugin :sessions, key: "insika.studio", secret: secret,
127
183
  max_seconds: SESSION_MAX_AGE, same_site: :lax
@@ -140,11 +196,6 @@ module Studio
140
196
  def derive_secret(admin_token)
141
197
  Digest::SHA512.hexdigest("insika-studio-session-v1:#{admin_token}")
142
198
  end
143
-
144
- # --- Restart recommended — per-process state -------------------
145
- def restart_needed? = @restart_needed == true
146
- def mark_restart_needed! = (@restart_needed = true)
147
- def clear_restart_needed! = (@restart_needed = false)
148
199
  end
149
200
 
150
201
  route do |r|
@@ -184,6 +235,18 @@ module Studio
184
235
  end
185
236
  end
186
237
 
238
+ # The signed artifact link: the ONLY path that serves an artifact without
239
+ # a Studio session (the point of a sharing link). Without a signing key
240
+ # there is NO signed surface — a forged link 404s, never a login redirect
241
+ # (a link that cannot exist must not advertise a door).
242
+ r.on "artifacts", "s" do
243
+ r.on String do |id|
244
+ r.get do
245
+ serve_signed_artifact(utf8(id), r.params["exp"], r.params["sig"])
246
+ end
247
+ end
248
+ end
249
+
187
250
  # --- FAIL-CLOSED: from here down requires an authenticated session ----
188
251
  r.redirect("/studio/login") unless authenticated?
189
252
 
@@ -193,14 +256,6 @@ module Studio
193
256
  r.redirect("/studio/login")
194
257
  end
195
258
 
196
- # Dismisses the "restart recommended" banner without restarting (the operator
197
- # acknowledges and moves on). A real restart clears the flag on its own.
198
- r.post "restart-ack" do
199
- check_csrf!
200
- self.class.clear_restart_needed!
201
- r.redirect(safe_back(r.params["back"]))
202
- end
203
-
204
259
  # GET /studio/events — the live transcript's SSE, on the STUDIO's own auth.
205
260
  # `EventSource` cannot send an Authorization header, so the browser used to read
206
261
  # the server's `/v1/events` directly — which is why that route had to stay open to
@@ -233,11 +288,18 @@ module Studio
233
288
  r.is do
234
289
  r.get do
235
290
  @agents = insika[:profile_source].all.sort_by(&:id)
291
+ # WS7 scorecard: the LAST outcome per agent, computed once for the
292
+ # whole grid (a store scan per card would be n scans). Series live
293
+ # on the agent detail — one agent's periods, not n charts here.
294
+ @latest_outcomes = insika[:outcome_store]&.latest_per_agent
295
+ # "New from template" gallery — cheap (frontmatter
296
+ # parse only, no evaluation) so it's safe on every render.
297
+ @templates = Insika::Templates.all
236
298
  view("agents")
237
299
  end
238
300
 
239
301
  # POST /studio/agents — creates an agent ("everyone creates
240
- # their own BIA"). Fires :create_agent; redirects to the new one's detail.
302
+ # their own agent"). Fires :create_agent; redirects to the new one's detail.
241
303
  r.post do
242
304
  check_csrf!
243
305
  id = presence(r.params["id"])
@@ -252,6 +314,19 @@ module Studio
252
314
  end
253
315
  end
254
316
 
317
+ # /studio/agents/from_template/:name — "New from template" gallery.
318
+ # Must be declared BEFORE the generic `r.on String do |id|`
319
+ # below, or Roda would treat "from_template" as an agent id.
320
+ r.on "from_template", String do |name|
321
+ r.post do
322
+ check_csrf!
323
+ result = with_flash("Created from template '#{name}'.") do
324
+ create_agent_from_template(name)
325
+ end
326
+ r.redirect(result ? agent_path(result[:agent_id]) : "/studio/agents")
327
+ end
328
+ end
329
+
255
330
  # /studio/agents/:id — an agent's authoring page.
256
331
  r.on String do |id|
257
332
  id = utf8(id)
@@ -269,20 +344,86 @@ module Studio
269
344
  with_flash("Configuration saved.") do
270
345
  dispatch(:update_agent, config_patch(r))
271
346
  end
272
- r.redirect(agent_path(id))
347
+ group = CONFIG_SECTIONS.include?(r.params["cfg"]) ? r.params["cfg"] : "model"
348
+ r.redirect(agent_path(id, "config", "cfg=#{group}"))
349
+ end
350
+
351
+ # Automatic-loop triggers, per agent. The loops (distillation,
352
+ # harvest, follow-up firing, funnel fold) already run on their own
353
+ # worker fibers when the agent declares them; these buttons exist so
354
+ # an operator can ask for a pass NOW, without waiting for the
355
+ # window. All three dispatch bus commands — the Studio never runs a
356
+ # loop itself.
357
+ r.post "distill" do
358
+ check_csrf!
359
+ done = 0
360
+ skipped = 0
361
+ begin
362
+ due_distill_sessions(id).each do |sid|
363
+ result = dispatch(:run_distillation, { session_id: sid })
364
+ if result && result[:distilled]
365
+ done += 1
366
+ else
367
+ skipped += 1
368
+ end
369
+ end
370
+ flash["notice"] = "Distilled #{done} session(s)" + (skipped.positive? ? " (#{skipped} skipped)." : ".")
371
+ rescue Insika::ValidationError, Insika::NotFoundError => e
372
+ flash["error"] = e.message
373
+ end
374
+ r.redirect(agent_path(id, "loops"))
375
+ end
376
+
377
+ r.post "harvest" do
378
+ check_csrf!
379
+ begin
380
+ result = dispatch(:run_harvest, { agent: id })
381
+ if result && result[:mined]
382
+ flash["notice"] = "Harvest run finished — #{result[:candidates]} candidate(s) mined."
383
+ elsif result && result[:skipped]
384
+ flash["notice"] = "Nothing mined — #{result[:skipped]}."
385
+ else
386
+ flash["notice"] = "Harvest run finished."
387
+ end
388
+ rescue Insika::ValidationError, Insika::NotFoundError => e
389
+ flash["error"] = e.message
390
+ end
391
+ r.redirect(agent_path(id, "loops"))
392
+ end
393
+
394
+ r.post "refinement" do
395
+ check_csrf!
396
+ with_flash("Refinement run finished — see the Refinement page.") do
397
+ dispatch(:run_refinement, { agent: id })
398
+ end
399
+ r.redirect(agent_path(id, "loops"))
273
400
  end
274
401
 
275
402
  # Store-backed prompts. Writing also ensures the file
276
403
  # enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
277
404
  # prompt_files is synced by the Commands themselves (write/delete
278
405
  # register/remove the file) — the Studio just dispatches the operation.
406
+ #
407
+ # The prompts section renders as a drill (master file list | editor), so
408
+ # the file under edit rides the URL like skills does: GET .../prompts/:file
409
+ # opens that file, .../prompts/new opens the create form.
279
410
  r.on "prompts" do
411
+ r.get "new" do
412
+ @prompt_selected = ""
413
+ render_agent_detail
414
+ end
415
+
416
+ r.get String do |file|
417
+ @prompt_selected = utf8(file)
418
+ render_agent_detail
419
+ end
420
+
280
421
  r.post "delete" do
281
422
  check_csrf!
282
423
  with_flash("Prompt removed.") do
283
424
  dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
284
425
  end
285
- r.redirect(agent_path(id))
426
+ r.redirect(agent_path(id, "prompts"))
286
427
  end
287
428
 
288
429
  r.post "restore" do
@@ -293,7 +434,7 @@ module Studio
293
434
  version: r.params["version"]
294
435
  })
295
436
  end
296
- r.redirect(agent_path(id))
437
+ r.redirect(prompt_edit_path(id, r.params["file"]))
297
438
  end
298
439
 
299
440
  r.post do
@@ -303,7 +444,7 @@ module Studio
303
444
  agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
304
445
  })
305
446
  end
306
- r.redirect(agent_path(id))
447
+ r.redirect(prompt_edit_path(id, r.params["file"]))
307
448
  end
308
449
  end
309
450
 
@@ -358,7 +499,7 @@ module Studio
358
499
 
359
500
  # Agent memory. Scoped by tenant = agent id — the
360
501
  # SAME tenant the playground uses when chatting, so what is edited
361
- # here is what the BIA reads on the turn. Each agent, its own memory.
502
+ # here is what the agent reads on the turn. Each agent, its own memory.
362
503
  r.on "memory" do
363
504
  r.post "fact" do
364
505
  check_csrf!
@@ -594,6 +735,26 @@ module Studio
594
735
  r.redirect("/studio/settings?s=llm")
595
736
  end
596
737
  end
738
+
739
+ # Demo data (OSS onboarding): populates the bundled "demo-store" agent
740
+ # so Funnels/Followups/Refinement/Approvals/Distillation/Evals all show
741
+ # real data at once. Same command the CLI's `insika demo:seed` runs —
742
+ # the Studio never seeds anything itself (constitutional rule).
743
+ r.post "seed-demo-data" do
744
+ check_csrf!
745
+ begin
746
+ result = dispatch(:seed_demo_data, { force: r.params["force"] == "1" })
747
+ flash["notice"] = if result[:seeded]
748
+ "Seeded '#{result[:agent]}': " +
749
+ result[:counts].map { |k, v| "#{v} #{k}" }.join(", ") + "."
750
+ else
751
+ "Already seeded (#{result[:reason]}) — check 'reseed' to seed again."
752
+ end
753
+ rescue Insika::ValidationError, Insika::NotFoundError => e
754
+ flash["error"] = e.message
755
+ end
756
+ r.redirect("/studio/settings?s=demo")
757
+ end
597
758
  end
598
759
 
599
760
  # --- MCP: instances with masked credentials ------------------
@@ -602,20 +763,56 @@ module Studio
602
763
  r.get { render_mcp }
603
764
  r.post do
604
765
  check_csrf!
605
- with_flash("MCP instance saved.") do
606
- dispatch(:upsert_mcp, mcp_patch(r))
607
- # MCP servers are wired at runtime boot; the new instance
608
- # only takes effect after a restart. Lights the "restart" banner.
609
- self.class.mark_restart_needed!
766
+ name = nil
767
+ result = with_flash("MCP instance saved.") do
768
+ payload = mcp_patch(r)
769
+ name = payload[:name]
770
+ dispatch(:upsert_mcp, payload)
610
771
  end
611
- r.redirect("/studio/mcp")
772
+ r.redirect(mcp_path(result ? name : presence(r.params["name"])))
612
773
  end
613
774
  end
614
775
  r.post "delete" do
615
776
  check_csrf!
616
777
  with_flash("MCP instance removed.") do
617
778
  dispatch(:delete_mcp, { name: presence(r.params["name"]) })
618
- self.class.mark_restart_needed!
779
+ end
780
+ r.redirect("/studio/mcp")
781
+ end
782
+ # "Test connection": connects LIVE via the same
783
+ # :refresh_mcp_tools Command the CLI's `insika mcp test`/`refresh` and
784
+ # the /v1/mcp/:name/import route use — writes tools_cache either way,
785
+ # `test` vs `refresh` is purely a CLI naming distinction, moot here
786
+ # since the instance card already displays the cache persistently.
787
+ # Rescues StandardError, not just Insika::Error: a transport failure
788
+ # is the ruby_llm-mcp gem's own error class, and turning it into a
789
+ # clean flash instead of a 500 is this caller's job (same discipline
790
+ # as the CLI's `mcp_run`).
791
+ r.post "test" do
792
+ check_csrf!
793
+ name = presence(r.params["name"])
794
+ begin
795
+ tools = dispatch(:refresh_mcp_tools, { name: name })[:tools]
796
+ flash["notice"] = "MCP instance '#{name}': connected, #{tools.size} tool(s)."
797
+ rescue StandardError => e
798
+ flash["error"] = "MCP instance '#{name}': #{e.message}"
799
+ end
800
+ r.redirect(mcp_path(name))
801
+ end
802
+ # "Import JSON": the same `mcpServers` document CLI's
803
+ # `insika mcp import` and Claude Desktop/Cursor already use. Studio
804
+ # never writes a store directly (constitutional rule) — `import_mcp_json`
805
+ # fans the document out into the SAME :upsert_mcp Command a single
806
+ # instance save would use, one dispatch per entry.
807
+ r.post "import" do
808
+ check_csrf!
809
+ begin
810
+ records = import_mcp_json(r.params["json"].to_s)
811
+ flash["notice"] = "Imported #{records.size} MCP instance(s): #{records.map { |rec| rec["name"] }.join(", ")}."
812
+ rescue JSON::ParserError
813
+ flash["error"] = "Invalid JSON."
814
+ rescue Insika::ValidationError, Insika::NotFoundError => e
815
+ flash["error"] = e.message
619
816
  end
620
817
  r.redirect("/studio/mcp")
621
818
  end
@@ -662,6 +859,10 @@ module Studio
662
859
  end
663
860
 
664
861
  # --- History: read-only viewer of a session ------------------
862
+ # Three zones: conversation list | transcript |
863
+ # metadata margin. The master column is the same recent-sessions list
864
+ # the chats index renders; rows frame-navigate, so a Turbo-Frame request
865
+ # renders the two right zones alone.
665
866
  r.on "sessions" do
666
867
  r.on String do |sid|
667
868
  sid = utf8(sid)
@@ -669,12 +870,97 @@ module Studio
669
870
  @session = insika[:session_store]&.find(sid)
670
871
  next_404 unless @session
671
872
 
873
+ # master column: recent conversations, current one active
874
+ @sessions = recent_sessions(limit: 60)
672
875
  # Session's tool-call trace (debug): grouped by turn in the view.
673
876
  @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
674
877
  .group_by { |t| t["turn"] }
675
878
  # Context breakdown per turn: chronological entries.
676
879
  @context_traces = insika[:context_trace_store]&.for_session(sid) || []
677
- view("session")
880
+ # the stage history — the session's outcomes folded
881
+ # into the agent's declared stages (unmapped kinds render raw).
882
+ @stage_history = stage_history_for(sid)
883
+ if turbo_frame?("session-detail")
884
+ render("session", locals: { frame_only: true }, layout: false)
885
+ else
886
+ view("session", locals: { frame_only: false })
887
+ end
888
+ end
889
+ end
890
+ end
891
+
892
+ # --- Customers: the memory drill ----------------
893
+ # Derived from the store, not a registry: the index enumerates memory
894
+ # cells and classifies them by shape (D6). Every mutation dispatches a
895
+ # Command on the bus — the transport's constitutional rule (app.rb:21).
896
+ # The cell scope in the URL is parsed back into the (tenant, customer)
897
+ # pair: a "memory:acme:c-1" URL must edit the SAME cell the turn reads
898
+ # (E1 live) — passing the raw scope as `tenant` would double-prefix it.
899
+ r.on "customers" do
900
+ r.is do
901
+ r.get { render_customers }
902
+ end
903
+
904
+ r.on String do |raw_scope|
905
+ # Roda captures the segment URL-encoded; a customer cell contains
906
+ # colons (memory:acme:c-1), so decode before parsing.
907
+ scope = Rack::Utils.unescape_path(utf8(raw_scope))
908
+ cell = Insika::MemoryStore.parse_cell(scope)
909
+
910
+ r.get { render_customer(scope, cell) }
911
+
912
+ # Add/edit a fact inline (upsert) — the operator's stamp + audit.
913
+ r.post "fact" do
914
+ check_csrf!
915
+ with_flash("Fact saved.") do
916
+ dispatch(:memory_put_fact, {
917
+ tenant: cell[:tenant], customer: cell[:customer],
918
+ key: presence(r.params["key"]), value: r.params["value"].to_s,
919
+ expires_at: presence(r.params["expires_at"]), operator: "studio"
920
+ })
921
+ end
922
+ r.redirect(customer_path(scope))
923
+ end
924
+
925
+ r.post "forget-fact" do
926
+ check_csrf!
927
+ with_flash("Fact forgotten.") do
928
+ dispatch(:memory_forget_fact, {
929
+ tenant: cell[:tenant], customer: cell[:customer],
930
+ key: presence(r.params["key"]), operator: "studio"
931
+ })
932
+ end
933
+ r.redirect(customer_path(scope))
934
+ end
935
+
936
+ # The LGPD access right: JSON download of the cell's content (D7 —
937
+ # the RETURN value is the download; the event stays counts-only). A
938
+ # cell with no customer (a _default cell, reachable by URL) is a
939
+ # ValidationError -> red flash, never a 500.
940
+ r.post "export" do
941
+ check_csrf!
942
+ result = dispatch(:export_customer_memory, {
943
+ tenant: cell[:tenant], customer: cell[:customer],
944
+ operator: "studio"
945
+ })
946
+ response["content-type"] = "application/json"
947
+ response["content-disposition"] =
948
+ %(attachment; filename="memory-#{Rack::Utils.escape_path(scope.tr(':', '-'))}.json")
949
+ JSON.pretty_generate(result)
950
+ rescue Insika::ValidationError => e
951
+ flash["error"] = e.message
952
+ r.redirect(customer_path(scope))
953
+ end
954
+
955
+ # The LGPD forget — purges the cell AND the customer's sessions.
956
+ r.post "forget" do
957
+ check_csrf!
958
+ with_flash("Customer forgotten.") do
959
+ dispatch(:forget_customer, {
960
+ tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
961
+ })
962
+ end
963
+ r.redirect("/studio/customers")
678
964
  end
679
965
  end
680
966
  end
@@ -803,6 +1089,247 @@ end
803
1089
  end
804
1090
  end
805
1091
 
1092
+ # --- Harvest: the gated skill loop ----------------------
1093
+ # Reads the HarvestStore directly (the Studio's constitutional split);
1094
+ # every mutation — mine now, gate, promote, reject, rollback — dispatches
1095
+ # a bus command. The gate POST is slow on purpose: it replays the golden
1096
+ # set, like Refinement's propose button.
1097
+ r.on "harvest" do
1098
+ r.is do
1099
+ r.get { render_harvest }
1100
+ r.post do # mine now (manual trigger, D10)
1101
+ check_csrf!
1102
+ agent = presence(r.params["agent"])
1103
+ payload = { agent: agent, full: r.params["full"] == "1" }
1104
+ control_action(:run_harvest, payload, ok: "Harvest run finished.")
1105
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1106
+ end
1107
+ end
1108
+
1109
+ r.post "gate" do # the double gate on one candidate (slow — replay)
1110
+ check_csrf!
1111
+ agent = presence(r.params["agent"])
1112
+ payload = { candidate_id: presence(r.params["candidate_id"]) }
1113
+ control_action(:gate_harvest, payload, ok: "Gated — see the verdicts below.")
1114
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1115
+ end
1116
+
1117
+ r.post "promote" do
1118
+ check_csrf!
1119
+ agent = presence(r.params["agent"])
1120
+ payload = { candidate_id: presence(r.params["candidate_id"]),
1121
+ operator: "studio", note: presence(r.params["note"]) }.compact
1122
+ control_action(:promote_harvest, payload,
1123
+ ok: "Promoted — the skill is live for this store.")
1124
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1125
+ end
1126
+
1127
+ r.post "reject" do
1128
+ check_csrf!
1129
+ agent = presence(r.params["agent"])
1130
+ payload = { candidate_id: presence(r.params["candidate_id"]),
1131
+ operator: "studio", note: presence(r.params["note"]) }.compact
1132
+ control_action(:reject_harvest, payload, ok: "Skill rejected.")
1133
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1134
+ end
1135
+
1136
+ r.post "rollback" do
1137
+ check_csrf!
1138
+ agent = presence(r.params["agent"])
1139
+ payload = { snapshot_ref: presence(r.params["snapshot_ref"]),
1140
+ operator: "studio", reason: presence(r.params["reason"]) }.compact
1141
+ control_action(:rollback_harvest, payload, ok: "Rolled back to the snapshot.")
1142
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1143
+ end
1144
+ end
1145
+
1146
+ # --- Knowledge: concepts learned from finished conversations --
1147
+ # Reads the KnowledgeStore directly (the Studio's constitutional split);
1148
+ # every mutation — write (including an operator's hand-authored concept
1149
+ # or a `provenance: policy` promotion), delete, restore — dispatches a
1150
+ # bus command. Single-agent-scoped like Harvest, not shared like Skills:
1151
+ # the store is a real per-agent (+ optional tenant) partition.
1152
+ r.on "knowledge" do
1153
+ r.is do
1154
+ r.get { render_knowledge }
1155
+ r.post do
1156
+ check_csrf!
1157
+ agent = presence(r.params["agent"])
1158
+ name = presence(r.params["name"])
1159
+ tenant = presence(r.params["tenant"])
1160
+ with_flash("Concept saved.") do
1161
+ dispatch(:write_concept, { agent: agent, name: name, tenant: tenant,
1162
+ content: r.params["content"].to_s }.compact)
1163
+ end
1164
+ r.redirect(knowledge_path(agent, name, tenant))
1165
+ end
1166
+ end
1167
+
1168
+ r.get "new" do
1169
+ @agents = insika[:profile_source].ids.sort
1170
+ @agent = presence(r.params["agent"]) || @agents.first
1171
+ @tenant = presence(r.params["tenant"])
1172
+ load_knowledge_master(agent: @agent, tenant: @tenant)
1173
+ @selected = ""
1174
+ @concept_content = new_concept_template
1175
+ view("knowledge")
1176
+ end
1177
+
1178
+ r.on String do |name|
1179
+ name = utf8(name)
1180
+ r.is do
1181
+ r.get do
1182
+ @agents = insika[:profile_source].ids.sort
1183
+ @agent = presence(r.params["agent"]) || @agents.first
1184
+ @tenant = presence(r.params["tenant"])
1185
+ load_knowledge_master(agent: @agent, tenant: @tenant)
1186
+ @selected = name
1187
+ @concept_content = concept_source(@agent, name, tenant: @tenant)
1188
+ @selected_concept = Insika::Knowledge::Concept.parse(@concept_content)
1189
+ @concept_versions = insika[:knowledge_store]&.versions(@agent, name, tenant: @tenant)
1190
+ view("knowledge")
1191
+ end
1192
+ end
1193
+
1194
+ r.post "delete" do
1195
+ check_csrf!
1196
+ agent = presence(r.params["agent"])
1197
+ tenant = presence(r.params["tenant"])
1198
+ with_flash("Concept removed.") do
1199
+ dispatch(:delete_concept, { agent: agent, name: name, tenant: tenant }.compact)
1200
+ end
1201
+ r.redirect("/studio/knowledge?agent=#{Rack::Utils.escape(agent.to_s)}")
1202
+ end
1203
+
1204
+ r.post "restore" do
1205
+ check_csrf!
1206
+ agent = presence(r.params["agent"])
1207
+ tenant = presence(r.params["tenant"])
1208
+ with_flash("Version restored.") do
1209
+ dispatch(:restore_concept, { agent: agent, name: name, tenant: tenant,
1210
+ version: r.params["version"] }.compact)
1211
+ end
1212
+ r.redirect(knowledge_path(agent, name, tenant))
1213
+ end
1214
+ end
1215
+ end
1216
+
1217
+ # --- Facts: the human gate on distilled proposals --------
1218
+ # Reads ProposalStore directly (the Studio's constitutional split — reads
1219
+ # hit the stores, mutations go through the bus); the ONLY mutation — the
1220
+ # approve/reject/dismiss answer — dispatches :resolve_proposal.
1221
+ r.on "facts" do
1222
+ r.get { render_facts }
1223
+ r.post "resolve" do
1224
+ check_csrf!
1225
+ decision = presence(r.params["decision"])
1226
+ ok = { "approved" => "Fact saved to memory.", "rejected" => "Proposal rejected.",
1227
+ "dismissed" => "Proposal dismissed — it will not be proposed again." }[decision]
1228
+ with_flash(ok || "Proposal resolved.") do
1229
+ dispatch(:resolve_proposal, { proposal_id: presence(r.params["proposal_id"]),
1230
+ decision: decision, note: presence(r.params["note"]),
1231
+ operator: "studio" })
1232
+ end
1233
+ filter = presence(r.params["filter"])
1234
+ r.redirect("/studio/facts#{filter ? "?store=#{Rack::Utils.escape(filter)}" : ""}")
1235
+ end
1236
+ end
1237
+
1238
+ # --- Parity: the running shadow verdict -----------------
1239
+ # Reads stores and folds the verdict on demand; the judge button is a slow
1240
+ # synchronous POST, exactly like Refinement's gate, for the same reason: it
1241
+ # returns when the answer is real. There is deliberately no "freeze the
1242
+ # criterion" button — freezing is a git commit, reviewed by a human.
1243
+ r.on "parity" do
1244
+ r.is do
1245
+ r.get { render_parity }
1246
+ r.post do
1247
+ check_csrf!
1248
+ agent = presence(r.params["agent"])
1249
+ limit = presence(r.params["limit"]).to_i
1250
+ payload = { agent: agent }.compact
1251
+ payload[:limit] = limit if limit.positive?
1252
+ control_action(:judge_shadow_pairs, payload,
1253
+ ok: "Judged — the verdict below is recomputed.")
1254
+ r.redirect("/studio/parity?agent=#{Rack::Utils.escape(agent.to_s)}")
1255
+ end
1256
+ end
1257
+ end
1258
+
1259
+ # --- Funnel: the outcome funnel per store ----------------
1260
+ # Reads the fold's cells + the BudgetLedger's current counters directly
1261
+ # (D7 — the scorecard's constitutional split); the ONE mutation — the
1262
+ # baseline freeze — dispatches :freeze_funnel_baseline on the bus.
1263
+ r.on "funnel" do
1264
+ r.get { render_funnel }
1265
+ r.on String do |agent_id|
1266
+ agent_id = utf8(agent_id)
1267
+ r.post "freeze" do
1268
+ check_csrf!
1269
+ with_flash("Baseline frozen.") do
1270
+ dispatch(:freeze_funnel_baseline,
1271
+ { agent: agent_id,
1272
+ from: presence(r.params["from"]),
1273
+ to: presence(r.params["to"]),
1274
+ # the card's hidden tenant field: WITHOUT it, a
1275
+ # multi-tenant deployment freezes the "platform" pair
1276
+ # while the operator believes they froze the store's.
1277
+ tenant: presence(r.params["tenant"]),
1278
+ operator: "studio" })
1279
+ end
1280
+ r.redirect("/studio/funnel")
1281
+ end
1282
+ end
1283
+ end
1284
+
1285
+ # --- Follow-ups: the schedule records + the A/B readout --
1286
+ # Reads FollowupStore/ContactStore/OutcomeStore directly (D10 — the
1287
+ # Studio's constitutional split); the ONLY mutations — cancel a pending
1288
+ # record, force-revoke a contact (an opt-out event that never arrived) —
1289
+ # dispatch bus commands.
1290
+ r.on "followups" do
1291
+ r.get { render_followups }
1292
+ r.on String do |agent_id|
1293
+ agent_id = utf8(agent_id)
1294
+ r.post "cancel" do
1295
+ check_csrf!
1296
+ with_flash("Follow-up cancelled.") do
1297
+ dispatch(:cancel_followup, { followup_id: r.params["id"] })
1298
+ end
1299
+ r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
1300
+ end
1301
+ r.post "revoke" do
1302
+ check_csrf!
1303
+ with_flash("Contact revoked — every pending follow-up cancelled.") do
1304
+ dispatch(:revoke_contact,
1305
+ { customer: r.params["customer"], operator: "studio" },
1306
+ tenant: presence(r.params["tenant"]))
1307
+ end
1308
+ r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
1309
+ end
1310
+ end
1311
+ end
1312
+
1313
+ # --- Artifacts: the report destination -------------------
1314
+ # Reads the ArtifactStore directly (the list, the preview, the content);
1315
+ # the ONLY mutation — delete — dispatches :delete_artifact on the bus
1316
+ # (the Studio never writes a store directly).
1317
+ r.on "artifacts" do
1318
+ r.is { r.get { render_artifacts } }
1319
+ r.on String do |id|
1320
+ id = utf8(id)
1321
+ r.get "content" do
1322
+ serve_artifact_content(id)
1323
+ end
1324
+ r.post "delete" do
1325
+ check_csrf!
1326
+ with_flash("Artifact deleted.") { dispatch(:delete_artifact, { id: id }) }
1327
+ r.redirect("/studio/artifacts?agent=#{Rack::Utils.escape(presence(r.params['agent']).to_s)}")
1328
+ end
1329
+ r.is { r.get { render_artifact(id) } }
1330
+ end
1331
+ end
1332
+
806
1333
  # Playground: sends `send_message` (the SAME Command as the API) and streams the
807
1334
  # response live through the `live-transcript` island (SSE from /studio/events).
808
1335
  r.on "playground" do
@@ -810,6 +1337,14 @@ end
810
1337
  @agent = presence(r.params["agent"]) || default_agent
811
1338
  @session_id = presence(r.params["session_id"])
812
1339
  @agents = insika[:profile_source].ids.sort
1340
+ # Session combobox: recent conversations as <datalist> suggestions for the
1341
+ # session field. The open session always appears, even if it fell off the
1342
+ # recent list (it is the one being edited).
1343
+ @recent = recent_sessions(limit: 8)
1344
+ if @session_id && @recent.none? { |s| s.id == @session_id }
1345
+ current = insika[:session_store]&.find(@session_id)
1346
+ @recent.unshift(current) if current
1347
+ end
813
1348
  # Server-side echo + continuity: render the session's persisted
814
1349
  # transcript as bubbles. The user's message is only persisted at the END
815
1350
  # of the turn, so the just-sent message rides a one-shot flash bubble
@@ -876,12 +1411,30 @@ end
876
1411
  ]],
877
1412
  ["operate", [
878
1413
  ["Chats", "/studio/chats", :chats],
1414
+ ["Customers", "/studio/customers", :chats],
879
1415
  ["Playground", "/studio/playground", :playground],
1416
+ ["Funnel", "/studio/funnel", :funnel],
1417
+ # the Follow-ups page — scheduled, fired, blocked and
1418
+ # cancelled records per agent + the per-arm A/B readout card.
1419
+ ["Follow-ups", "/studio/followups", :followups],
1420
+ # the Artifacts page — the report destination: per-agent
1421
+ # list, sandboxed preview, signed sharing link.
1422
+ ["Artifacts", "/studio/artifacts", :artifacts],
1423
+ # the Facts (wiki) page — the human gate on distilled
1424
+ # proposals (approve/reject/dismiss, the latch, the CAS re-present).
1425
+ ["Facts", "/studio/facts", :facts],
1426
+ # the Harvest page — the human gate on mined skills
1427
+ # (gate, promote, reject, rollback; the append-only promotion log).
1428
+ ["Harvest", "/studio/harvest", :harvest],
1429
+ # the Knowledge page — concepts the engine extracted from
1430
+ # finished conversations; edit, delete, resolve a conflict.
1431
+ ["Knowledge", "/studio/knowledge", :knowledge],
880
1432
  ["Tasks", "/studio/tasks", :tasks],
881
1433
  ["Approvals", "/studio/approvals", :approvals],
882
1434
  ["Refinement", "/studio/refinement", :refinement],
883
1435
  ["Evals", "/studio/evals", :evals]
884
- ]]
1436
+ ] + parity_nav_item
1437
+ ]
885
1438
  ]
886
1439
  end
887
1440
 
@@ -889,6 +1442,16 @@ end
889
1442
 
890
1443
  def authenticated? = session["auth"] == true
891
1444
 
1445
+ # Is this request a Turbo frame navigation targeting `id`? Turbo sends
1446
+ # `Turbo-Frame: <id>` on every frame fetch (links with data-turbo-frame,
1447
+ # form submits inside a frame — including the GET after a 303 redirect).
1448
+ # The miller-column routes render their DETAIL pane alone for such a
1449
+ # request and the full two-column shell otherwise — the
1450
+ # standard Rails check, spelled for Roda. Non-matching frame ids and plain
1451
+ # browser hits both take the full page, so refresh/deep links always render
1452
+ # the whole screen.
1453
+ def turbo_frame?(id) = request.env["HTTP_TURBO_FRAME"] == id.to_s
1454
+
892
1455
  # Is the given nav href the current page? Robust to whether request.path
893
1456
  # carries the "/studio" mount prefix (it does under serve_real/config.ru, it
894
1457
  # doesn't in specs) — strip it from both sides, then exact- or prefix-match
@@ -911,9 +1474,7 @@ end
911
1474
  # styles. Memoized on the request instance so header and <meta> agree.
912
1475
  def csp_nonce = (@csp_nonce ||= (session["csp_nonce"] ||= SecureRandom.base64(16)))
913
1476
 
914
- # --- Polish: theme, health chip, restart banner ----------------
915
-
916
- def restart_needed? = self.class.restart_needed?
1477
+ # --- Polish: theme, health chip -------------------------------
917
1478
 
918
1479
  # Environment label for the sidebar identity chip — reflects the REAL runtime
919
1480
  # env (INSIKA_ENV → RACK_ENV → "local") so an operator always knows which box
@@ -962,7 +1523,7 @@ end
962
1523
 
963
1524
  def default_agent
964
1525
  ids = insika[:profile_source].ids
965
- ids.include?("bia") ? "bia" : (ids.first || "bia")
1526
+ ids.include?("demo") ? "demo" : (ids.first || "demo")
966
1527
  end
967
1528
 
968
1529
  # --- Transcript display helpers (playground + session viewer) ------------
@@ -978,6 +1539,18 @@ end
978
1539
  content.to_s
979
1540
  end
980
1541
 
1542
+ # `save_artifact`'s tool-result content is a plain Hash#to_s (RubyLLM tool
1543
+ # results are .to_s'd verbatim into the stored message — see Executor#
1544
+ # serialize_chat_message — never JSON-encoded): `{id: "...", url: "/studio/
1545
+ # artifacts/..."}`. A regex on purpose, not JSON.parse — it genuinely is
1546
+ # not JSON. -> the artifact path, or nil when this tool result isn't one
1547
+ # (every other tool's result renders exactly as before, unaffected).
1548
+ def artifact_link_in(content)
1549
+ return nil unless content.is_a?(String)
1550
+
1551
+ content[%r{url:\s*"(/studio/artifacts/[^"]+)"}, 1]
1552
+ end
1553
+
981
1554
  # An activation label from the context trace, as the operator reads it:
982
1555
  # "gift-concierge · trigger:presente". The REASON is the point of the card — a
983
1556
  # bare list of names answers "was something injected", never "which one did I
@@ -1049,6 +1622,19 @@ end
1049
1622
 
1050
1623
  def render_agent_detail
1051
1624
  id = @agent.id
1625
+ # Which subnav tab the frame should land on. Selecting a prompt file or
1626
+ # a config group is a real navigation (advances the frame + history),
1627
+ # which reconnects the `tabs` Stimulus controller — but Turbo's history
1628
+ # push drops the URL fragment (it rewrites the frame's src to the fetch
1629
+ # response's URL, which never carries one), so `location.hash` is gone
1630
+ # by the time it reconnects. Without this, every such click falls back
1631
+ # to the tabs' first tab ("config") instead of staying put.
1632
+ @active_tab = if !@prompt_selected.nil?
1633
+ "prompts"
1634
+ elsif request.params["cfg"]
1635
+ "config"
1636
+ end
1637
+ @config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
1052
1638
  store = insika[:agent_file_store]
1053
1639
  @prompt_files = Array(@agent.prompt_files).map do |name|
1054
1640
  {
@@ -1069,7 +1655,26 @@ end
1069
1655
  @facts = mem ? mem.facts(tenant: id) : []
1070
1656
  @notes = mem ? mem.notes(tenant: id, limit: 20) : []
1071
1657
  @recent_sessions = recent_sessions
1072
- view("agent_detail")
1658
+ # WS7: last outcome + per-day series for THIS agent. The grid already
1659
+ # shows the last-outcome pill; the series is the period view.
1660
+ outcomes = insika[:outcome_store]
1661
+ @latest_outcome = outcomes&.latest_per_agent&.[](id)
1662
+ @outcome_series = outcomes ? outcomes.series(agent: id) : {}
1663
+ # the per-agent cache-hit series (nil store -> the view's
1664
+ # empty state; same guard as the tool trace).
1665
+ @cache_series = insika[:cache_series_store]&.for_agent(id) || []
1666
+ # the agent's schedule rows (declaration + runtime state) —
1667
+ # the card shows next fire and the last run/skip alongside the editor.
1668
+ @schedules = insika[:schedule_store]&.for_agent(tenant: Insika::ScheduleEngine.tenant_for(@agent), agent: id) || []
1669
+ # the master column: the detail page IS the shell when
1670
+ # visited directly; only a frame request renders the pane alone.
1671
+ @agents = insika[:profile_source].all.sort_by(&:id)
1672
+ @latest_outcomes = insika[:outcome_store]&.latest_per_agent
1673
+ if turbo_frame?("agent-detail")
1674
+ render("agent_detail", locals: { frame_only: true }, layout: false)
1675
+ else
1676
+ view("agent_detail", locals: { frame_only: false })
1677
+ end
1073
1678
  end
1074
1679
 
1075
1680
  # A generation param off the profile (string-keyed by AgentProfile.build). Used
@@ -1080,6 +1685,93 @@ end
1080
1685
  params[key.to_s].nil? ? "" : params[key.to_s]
1081
1686
  end
1082
1687
 
1688
+ # A free-form config block (string-keyed by AgentProfile.build) rendered
1689
+ # as the JSON textarea the form parses back. Pretty-printed so an operator
1690
+ # actually reads the current state; "" when the block is absent.
1691
+ def agent_json(config)
1692
+ return "" if config.nil? || config.empty?
1693
+
1694
+ JSON.pretty_generate(config)
1695
+ rescue StandardError
1696
+ config.to_s
1697
+ end
1698
+
1699
+ # A list field as comma-joined text for the form's textarea.
1700
+ def agent_list(list)
1701
+ Array(list).join(", ")
1702
+ end
1703
+
1704
+ # A list field as newline-joined lines for the form's textarea.
1705
+ def agent_lines(list)
1706
+ Array(list).join("\n")
1707
+ end
1708
+
1709
+ # The shared "filter by agent" GET form used by every shared screen
1710
+ # (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
1711
+ # facts, parity). Same markup everywhere: a select that submits on change.
1712
+ def agent_filter_form(path, current)
1713
+ ids = insika[:profile_source].ids.sort
1714
+ options = [["", "all"]] + ids.map { |id| [id, id] }
1715
+ rows = options.map do |value, label|
1716
+ %(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
1717
+ end.join
1718
+ %(<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>)
1719
+ end
1720
+
1721
+ # The sessions of one agent — the session stamps its agent in
1722
+ # `vars["agent"]`, so any list of sessions is filterable by agent.
1723
+ def agent_sessions(sessions, agent)
1724
+ return sessions if agent.nil? || agent.empty?
1725
+
1726
+ sessions.select { |s| session_agent(s) == agent }
1727
+ end
1728
+
1729
+ def session_agent(session)
1730
+ vars = session.respond_to?(:vars) ? session.vars : nil
1731
+ vars.is_a?(Hash) ? vars["agent"].to_s : ""
1732
+ end
1733
+
1734
+ # The distill button's scope (the DistillEngine's own scan, scoped to ONE
1735
+ # agent): the agent's sessions that are idle past the pack's window, long
1736
+ # enough, and not yet distilled. Oldest first, capped — each entry is a
1737
+ # provider call, so one click stays bounded. Reads hit the stores; the
1738
+ # writes go through :run_distillation on the bus.
1739
+ def due_distill_sessions(agent_id, limit: 5)
1740
+ store = insika[:session_store]
1741
+ proposals = insika[:proposal_store]
1742
+ profile = insika[:profile_source].fetch(agent_id)
1743
+ return [] if profile.nil? || store.nil? || proposals.nil?
1744
+
1745
+ config = Insika::Coercion.deep_stringify(profile.distill) || {}
1746
+ idle_hours = positive_int(config["idle_hours"]) || 6
1747
+ min_messages = positive_int(config["min_messages"]) || 3
1748
+ cutoff = Time.now.utc - idle_hours * 3600
1749
+ store.each_id.filter_map do |sid|
1750
+ session = store.find(sid)
1751
+ next unless session
1752
+ next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
1753
+ next if Insika::Coercion.blank?(session.vars["customer"])
1754
+ next unless aged?(session.updated_at, cutoff)
1755
+ next if session.messages.size < min_messages
1756
+ next if proposals.distilled?(sid)
1757
+
1758
+ [sid, session.updated_at.to_s]
1759
+ end.sort_by(&:last).map(&:first).first(limit)
1760
+ end
1761
+
1762
+ def positive_int(value)
1763
+ v = value.to_i
1764
+ v.positive? ? v : nil
1765
+ end
1766
+
1767
+ def aged?(updated_at, cutoff)
1768
+ return false if Insika::Coercion.blank?(updated_at)
1769
+
1770
+ Time.iso8601(updated_at.to_s) <= cutoff
1771
+ rescue ArgumentError
1772
+ false
1773
+ end
1774
+
1083
1775
  # Renders the reasoning <select> shared by the agent config, settings and
1084
1776
  # playground (4-layer). `blank_label` names the empty option — the
1085
1777
  # "inherit the broader layer" / provider-default choice. Values come from a
@@ -1235,7 +1927,13 @@ end
1235
1927
  # default to the first agent so the pane is useful on landing.
1236
1928
  sel = request.params["a"]
1237
1929
  @sel_agent = (sel && @agents.find { |a| a.id == sel }) || @agents.first
1238
- view("tools")
1930
+ # Miller columns: the master rows frame-navigate (?a=),
1931
+ # so a Turbo-Frame request renders just the detail pane.
1932
+ if turbo_frame?("tool-detail")
1933
+ render("tools", locals: { frame_only: true }, layout: false)
1934
+ else
1935
+ view("tools", locals: { frame_only: false })
1936
+ end
1239
1937
  end
1240
1938
 
1241
1939
  # nil = all; otherwise the list. Pre-checks the checkboxes per agent.
@@ -1364,9 +2062,13 @@ end
1364
2062
  # At-a-glance dashboard: counts + activity + recent conversations. All from
1365
2063
  # data the Studio already reads (one scan of the session store); no new
1366
2064
  # metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
2065
+ # `?agent=` narrows every number to one agent (the sessions stamp their
2066
+ # author in `vars["agent"]`). The live layer (live_home_controller) only
2067
+ # repaints what this renders — it never computes its own baseline.
1367
2068
  def render_home
1368
2069
  ps = insika[:profile_source]
1369
- sessions = all_sessions
2070
+ @agent = presence(request.params["agent"])
2071
+ sessions = agent_sessions(all_sessions, @agent)
1370
2072
  @counts = {
1371
2073
  "conversations" => sessions.size,
1372
2074
  "messages" => sessions.sum { |s| Array(s.messages).size },
@@ -1381,6 +2083,15 @@ end
1381
2083
  @active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
1382
2084
  @recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
1383
2085
  @activity = activity_by_day(sessions, days: 14, now: now)
2086
+ # 24h sparkline: conversations touched per hour, oldest
2087
+ # first — the same session scan, bucketed finer.
2088
+ @activity_24h = activity_by_hour(sessions, hours: 24, now: now)
2089
+ # Trend affordances on the traffic stat cards: today vs yesterday from
2090
+ # the 14-day series (config counts — agents/skills/tools/providers —
2091
+ # have no daily shape and honestly show no trend).
2092
+ today, yesterday = @activity.last(2).map(&:last)
2093
+ @conv_trend = today - yesterday.to_i
2094
+ @msg_trend = message_delta(sessions, now)
1384
2095
  @persistence = insika.dig(:config, :persistence)
1385
2096
  view("home")
1386
2097
  end
@@ -1412,6 +2123,32 @@ end
1412
2123
  (0...days).to_a.reverse.map { |i| d = today - i; [d, buckets[d]] }
1413
2124
  end
1414
2125
 
2126
+ # [[hour_start, count], …] — one bucket per hour over the last `hours`,
2127
+ # oldest first, each bucket labeled by its starting hour. Feeds the 24h
2128
+ # sparkline; a session counts in the hour it was last
2129
+ # touched — the same reading "conversations touched" the daily chart uses.
2130
+ def activity_by_hour(sessions, hours:, now:)
2131
+ floor = Time.utc(now.year, now.month, now.day, now.hour) - (hours - 1) * 3600
2132
+ buckets = Hash.new(0)
2133
+ sessions.each do |s|
2134
+ t = parse_time(s.updated_at) or next
2135
+ h = Time.utc(t.year, t.month, t.day, t.hour)
2136
+ buckets[h] += 1 if h >= floor
2137
+ end
2138
+ (0...hours).map { |i| [floor + i * 3600, buckets[floor + i * 3600]] }
2139
+ end
2140
+
2141
+ # Messages in sessions touched today vs yesterday (the delta the messages
2142
+ # stat card shows). A conversation's whole message count lands in the day
2143
+ # it was last updated — coarse, but it is the same data the card counts.
2144
+ def message_delta(sessions, now)
2145
+ today = now.to_date
2146
+ sum = ->(date) do
2147
+ sessions.sum { |s| (t = parse_time(s.updated_at)) && t.to_date == date ? Array(s.messages).size : 0 }
2148
+ end
2149
+ sum.call(today) - sum.call(today - 1)
2150
+ end
2151
+
1415
2152
  # --- History -------------------------------------------------------------
1416
2153
 
1417
2154
  # Recent conversations (all agents — the Session doesn't stamp the agent that
@@ -1441,17 +2178,24 @@ end
1441
2178
 
1442
2179
  def session_preview(session)
1443
2180
  last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
1444
- last && last["content"].to_s
2181
+ # Structured content previews as JSON (message_content), never #inspect —
2182
+ # a hashrocket in a filter-text attribute is exactly the leak this guards.
2183
+ last && message_content(last["content"])
1445
2184
  end
1446
2185
 
1447
2186
  # --- Settings + LLM providers ----------------------------------
1448
2187
 
1449
- SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
2188
+ # The agent config tab's groups, in sidebar order. The view renders the
2189
+ # same keys; a bogus ?cfg= falls back to the first group.
2190
+ CONFIG_SECTIONS = %w[model guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
2191
+
2192
+ SETTINGS_SECTIONS = %w[general models edge evals llm demo].freeze
1450
2193
  def render_settings
1451
2194
  store = insika[:settings_store]
1452
2195
  @settings = store ? store.get : Insika::SettingsStore::DEFAULTS
1453
2196
  @providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
1454
2197
  @section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
2198
+ @persistence = insika[:config][:persistence].to_s
1455
2199
  view("settings")
1456
2200
  end
1457
2201
 
@@ -1463,12 +2207,73 @@ end
1463
2207
  # --- MCP -------------------------------------------------------
1464
2208
 
1465
2209
  def render_mcp
1466
- @instances = insika[:mcp_store] ? insika[:mcp_store].all : []
1467
- view("mcp")
2210
+ @instances = insika[:mcp_store] ? insika[:mcp_store].all.sort_by { |m| m["name"].to_s } : []
2211
+ # Miller columns: ?i= selects the instance whose editor
2212
+ # fills the detail pane. Absent = the first instance (the pane is useful
2213
+ # on landing, same convention as the tools matrix); "?i=" (empty) = the
2214
+ # empty state, which hosts the new/import forms — the master's "+ new"
2215
+ # link points there. A Turbo-Frame request renders the pane alone.
2216
+ raw = request.params["i"]
2217
+ @selected_mcp = if raw.nil?
2218
+ @instances.first
2219
+ else
2220
+ @instances.find { |m| m["name"] == raw }
2221
+ end
2222
+ if turbo_frame?("mcp-detail")
2223
+ render("mcp", locals: { frame_only: true }, layout: false)
2224
+ else
2225
+ view("mcp", locals: { frame_only: false })
2226
+ end
2227
+ end
2228
+
2229
+ # Where an MCP write redirects back to: the miller shell with the touched
2230
+ # instance still selected, so a frame submit lands on the saved editor
2231
+ # rather than the empty state. A deleted instance falls back to the shell.
2232
+ def mcp_path(name = nil)
2233
+ return "/studio/mcp" if Insika::Coercion.blank?(name)
2234
+
2235
+ "/studio/mcp?i=#{Rack::Utils.escape(name)}"
1468
2236
  end
1469
2237
 
1470
2238
  # mcp_patch moved to Studio::Forms.
1471
2239
 
2240
+ # Bulk `mcpServers` JSON import, routed through the bus:
2241
+ # gives Insika::McpJson.import the `#upsert` duck-type it expects while
2242
+ # every actual write still goes through :upsert_mcp — the adapter's
2243
+ # block closes over `self` (the App instance), so `dispatch` resolves
2244
+ # normally regardless of its own visibility. -> [Hash] masked records.
2245
+ def import_mcp_json(json)
2246
+ dispatcher = method(:dispatch)
2247
+ adapter = Object.new
2248
+ adapter.define_singleton_method(:upsert) { |attrs| dispatcher.call(:upsert_mcp, attrs) }
2249
+ Insika::McpJson.import(json, mcp_store: adapter)
2250
+ end
2251
+
2252
+ # Connection/discovery status chip for an ENABLED instance -
2253
+ # durable state read from the record, not a live probe: "N
2254
+ # tool(s)" means the last refresh cached that many, not that the server
2255
+ # is reachable RIGHT NOW (only "Test connection" tells you that; a failed
2256
+ # attempt surfaces as a flash, not a chip — McpStore holds no "last
2257
+ # error" field to persist). -> [label, pill_css_class].
2258
+ def mcp_status(record)
2259
+ if record["transport"] == "stdio" && !Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO"))
2260
+ return ["stdio disabled", "err"]
2261
+ end
2262
+
2263
+ tools = Array(record["tools_cache"])
2264
+ return ["untested", ""] if tools.empty?
2265
+
2266
+ ["#{tools.size} tool(s)", "ok"]
2267
+ end
2268
+
2269
+ # Haystack for the /mcp list-filter box: name, transport, on/off, and the
2270
+ # same status label the pill shows — so typing "stdio", "off", "err" or
2271
+ # "untested" narrows the list, not just a name search.
2272
+ def mcp_filter_text(record)
2273
+ status_label, = mcp_status(record)
2274
+ [record["name"], record["transport"], record["enabled"] ? "on enabled" : "off disabled", status_label].join(" ")
2275
+ end
2276
+
1472
2277
  # --- System-files ----------------------------------------------
1473
2278
 
1474
2279
  def render_system_files
@@ -1483,20 +2288,67 @@ end
1483
2288
  # --- Chats -----------------------------------------------------
1484
2289
 
1485
2290
  def render_chats
1486
- @sessions = recent_sessions(limit: 100)
2291
+ @agent = presence(request.params["agent"])
2292
+ @sessions = agent_sessions(recent_sessions(limit: 100), @agent)
1487
2293
  view("chats")
1488
2294
  end
1489
2295
 
2296
+ # --- Customers ----------------------------------
2297
+
2298
+ # The index: every CUSTOMER memory cell, grouped by tenant. The caller
2299
+ # passes `reserved:` (profile ids + _default) so the agent-memory tab's
2300
+ # cells and the shared cell stay out of the drill (D6 — the store is
2301
+ # policy-free). Fact count per cell via a keys-only list (no payload
2302
+ # reads per conversation on every render).
2303
+ def render_customers
2304
+ mem = insika[:memory_store]
2305
+ agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
2306
+ @agent = presence(request.params["agent"])
2307
+ @rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
2308
+ { cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
2309
+ end : []
2310
+ # ?agent= narrows to one tenant — the memory scope is the agent (the
2311
+ # agent-memory tab writes under tenant = agent id).
2312
+ @rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
2313
+ @by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
2314
+ view("customers")
2315
+ end
2316
+
2317
+ # The detail: the parsed cell, its facts (expired excluded by C1), notes
2318
+ # (append-only), and the operator audit (digests + counts, never content).
2319
+ def render_customer(scope, cell)
2320
+ @cell = cell
2321
+ mem = insika[:memory_store]
2322
+ @facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
2323
+ @notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
2324
+ @audit = insika[:memory_audit_store]&.for_cell(scope) || []
2325
+ view("customer")
2326
+ end
2327
+
2328
+ def customer_path(scope)
2329
+ # escape_path, not escape: the route decodes with unescape_path, which
2330
+ # does not turn '+' back into a space (escape's form-encoding would).
2331
+ "/studio/customers/#{Rack::Utils.escape_path(scope)}"
2332
+ end
2333
+
1490
2334
  # Tasks & Approvals --------------------------------
1491
2335
 
1492
2336
  # Task list, most-recently-updated first. Empty-state if no store was injected.
2337
+ # `?agent=` narrows to one agent (the task's command payload stamps it).
1493
2338
  def render_tasks
1494
2339
  store = insika[:task_store]
2340
+ @agent = presence(request.params["agent"])
1495
2341
  @tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
2342
+ @tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
1496
2343
  @tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
1497
2344
  view("tasks")
1498
2345
  end
1499
2346
 
2347
+ def task_agent(task)
2348
+ command = task.command
2349
+ command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
2350
+ end
2351
+
1500
2352
  # Task detail: @task is set by the route. Adds the open approvals for this task
1501
2353
  # and its latest checkpoint (both degrade to empty when the store is absent).
1502
2354
  def render_task_detail(id)
@@ -1506,11 +2358,16 @@ end
1506
2358
  end
1507
2359
 
1508
2360
  # Approvals inbox: every :pending action across tasks, each paired with its
1509
- # task (for the status pill + a link into the task detail).
2361
+ # task (for the status pill + a link into the task detail). `?agent=`
2362
+ # narrows to one agent (via the owning task).
1510
2363
  def render_approvals
1511
2364
  pstore = insika[:pending_action_store]
1512
2365
  tstore = insika[:task_store]
2366
+ @agent = presence(request.params["agent"])
1513
2367
  @approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
2368
+ if @agent
2369
+ @approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
2370
+ end
1514
2371
  view("approvals")
1515
2372
  end
1516
2373
 
@@ -1523,6 +2380,8 @@ def render_evals
1523
2380
  store = insika[:golden_store]
1524
2381
  @cases = store ? store.all : []
1525
2382
  @invalid = store ? store.invalid : []
2383
+ @filter = presence(request.params["agent"])
2384
+ @cases = @cases.select { |g| g.agent == @filter } if @filter
1526
2385
  @by_agent = @cases.group_by(&:agent).sort.to_h
1527
2386
  wanted = presence(request.params["id"])
1528
2387
  @case = wanted && @cases.find { |g| g.id == wanted }
@@ -1532,8 +2391,15 @@ end
1532
2391
 
1533
2392
  # The case as the YAML an operator edits — the same shape `evals/golden/**` holds,
1534
2393
  # so there is one format to learn and a pull request can review what was authored.
2394
+ # A SIMULATED case carries `persona:` instead of `turns:` — the two
2395
+ # shapes are the same YAML, and dropping either key would silently change what
2396
+ # the case tests.
1535
2397
  def golden_yaml(golden)
1536
- h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
2398
+ h = if golden.simulated?
2399
+ { "id" => golden.id, "agent" => golden.agent, "persona" => golden.persona.to_h }
2400
+ else
2401
+ { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
2402
+ end
1537
2403
  h["requires"] = golden.requires unless golden.requires.empty? # dropping it would un-skip the case
1538
2404
  h["reference"] = golden.reference unless golden.reference.empty? # …and this would un-compare it
1539
2405
  YAML.dump(h.merge("expect" => golden.expect))
@@ -1556,6 +2422,485 @@ end
1556
2422
  view("refinement")
1557
2423
  end
1558
2424
 
2425
+ # --- Parity -------------------------------------------------
2426
+
2427
+ # The nav row exists ONLY when a shadow channel is registered — the page is
2428
+ # not an invitation, unlike Refinement's.
2429
+ def parity_nav_item
2430
+ shadow_channel? ? [["Parity", "/studio/parity", :parity]] : []
2431
+ end
2432
+
2433
+ def shadow_channel?
2434
+ registry = insika[:channel_registry]
2435
+ return false unless registry
2436
+
2437
+ Array(registry.names).any? do |name|
2438
+ channel = registry.find(name)
2439
+ channel.respond_to?(:shadow?) && channel.shadow?
2440
+ end
2441
+ end
2442
+
2443
+ def parity_pairs
2444
+ insika[:shadow_pair_store] ? insika[:shadow_pair_store].each.to_a : []
2445
+ end
2446
+
2447
+ def render_parity
2448
+ @criterion = insika[:parity_criterion]
2449
+ @agent = presence(request.params["agent"])
2450
+ # ONE materialization per request: the fold and the pair list share this
2451
+ # array (the store's counts would scan the whole table a second time).
2452
+ pairs = parity_pairs
2453
+ pairs = pairs.select { |p| p.agent.to_s == @agent } if @agent
2454
+ @report = @criterion && Insika::Parity::Verdict.fold(pairs: pairs, criterion: @criterion)
2455
+ @pairs = pairs.sort_by { |p| p.created_at.to_s }.reverse.first(50)
2456
+ @pending = pairs.count { |p| p.status == :complete }
2457
+ if @criterion
2458
+ @judge_cost = @pending * @criterion.rule.min_judge_models * 2
2459
+ @judge_models = ((insika[:settings_store]&.get || {})["evals"] || {})
2460
+ end
2461
+ # Judge agreement : split/unknown from the fold, plus how many
2462
+ # verdicts flipped with presentation order.
2463
+ @agreement = { order_dependent: pairs.count { |p| p.verdict.is_a?(Hash) && p.verdict["order_dependent"] } }
2464
+ view("parity")
2465
+ end
2466
+
2467
+ # --- Artifacts ---------------------------------------------
2468
+
2469
+ # The restrictive CSP for artifact CONTENT (both the authenticated and the
2470
+ # signed route): artifact content is LLM output — untrusted. No script, no
2471
+ # external fetch, no forms; inline styles (the report's own styling) and
2472
+ # data: images (inline SVG) are the two things a report legitimately needs.
2473
+ ARTIFACT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
2474
+
2475
+ def artifact_store = insika[:artifact_store]
2476
+
2477
+ # -> { key:, ttl:, base_url: } | nil (no signing configured -> no signed surface).
2478
+ def artifact_signing = insika[:artifact_signing]
2479
+
2480
+ def artifact_tenant = presence(request.params["tenant"]) || "platform"
2481
+
2482
+ # The Artifacts page: per-agent list (the listing IS the history — newest
2483
+ # first). Reads the store directly; the only mutation (delete) dispatches
2484
+ # :delete_artifact on the bus.
2485
+ def render_artifacts
2486
+ @agents = insika[:profile_source].ids.sort
2487
+ @agent = presence(request.params["agent"])
2488
+ @signed_available = !artifact_signing.nil? && artifact_signing[:key].to_s.length.positive?
2489
+ # #all, not #for_agent/#for_tenant: the Studio doesn't reliably know
2490
+ # which tenant string an artifact was bound under (Playground turns
2491
+ # bind tenant=agent; a plain single-tenant API turn binds "platform") —
2492
+ # see ArtifactStore#all. Filtering by the record's own `agent` field
2493
+ # sidesteps the guess.
2494
+ @artifacts = artifact_store&.all(agent: @agent) || []
2495
+ view("artifacts")
2496
+ end
2497
+
2498
+ # The preview page: metadata + the artifact in a SANDBOXED iframe (no
2499
+ # allow-scripts / allow-same-origin / allow-forms — on top of the content
2500
+ # route's own restrictive CSP).
2501
+ def render_artifact(id)
2502
+ record = artifact_store&.find(id)
2503
+ return next_404 if record.nil?
2504
+
2505
+ @artifact = record
2506
+ @content_url = "/studio/artifacts/#{Rack::Utils.escape_path(id)}/content"
2507
+ @signed_url = signed_artifact_url(record.id)
2508
+ view("artifact")
2509
+ end
2510
+
2511
+ # The authenticated content route: raw content + the restrictive CSP. The
2512
+ # iframe (same-origin) and the channel-navigated operator read here.
2513
+ def serve_artifact_content(id)
2514
+ record = artifact_store&.find(id)
2515
+ return next_404 if record.nil?
2516
+
2517
+ serve_artifact_bytes(record)
2518
+ end
2519
+
2520
+ # The signed route: verifies the HMAC over (id, expiry) in constant time,
2521
+ # serves only what verifies AND is unexpired. Bad/expired/absent-key ->
2522
+ # 404 (never 403 — no oracle).
2523
+ def serve_signed_artifact(id, exp, sig)
2524
+ signing = artifact_signing
2525
+ key = signing && signing[:key].to_s
2526
+ return next_404 if key.to_s.empty?
2527
+
2528
+ return next_404 unless Insika::ArtifactSigning.valid?(id: id, token: sig, key: key, exp: exp)
2529
+
2530
+ record = artifact_store&.find(id)
2531
+ return next_404 if record.nil?
2532
+
2533
+ serve_artifact_bytes(record)
2534
+ end
2535
+
2536
+ # -> the signed sharing URL | nil (no key -> no signed surface).
2537
+ def signed_artifact_url(id)
2538
+ signing = artifact_signing
2539
+ return nil unless signing && signing[:key] && signing[:key].to_s.length.positive?
2540
+
2541
+ Insika::ArtifactSigning.url_for(id: id, base: signing[:base_url],
2542
+ key: signing[:key], ttl: signing[:ttl])
2543
+ end
2544
+
2545
+ def serve_artifact_bytes(record)
2546
+ response.headers["content-type"] = "#{record.mime}; charset=utf-8"
2547
+ response.headers["Content-Security-Policy"] = ARTIFACT_CSP
2548
+ response.headers["X-Content-Type-Options"] = "nosniff"
2549
+ response.headers["Cache-Control"] = "no-store"
2550
+ response.write(record.content)
2551
+ request.halt
2552
+ end
2553
+
2554
+ # --- Funnel ------------------------------------------------
2555
+
2556
+ # One card per STORE (agent) that declares a funnel, each with a block per
2557
+ # tenant that has folded cells. The only mutation on this page — the
2558
+ # baseline freeze — dispatches :freeze_funnel_baseline (D7).
2559
+ def render_funnel
2560
+ profiles = insika[:profile_source]&.all || []
2561
+ p = request.params["period"].to_i
2562
+ @period = p.positive? ? p : 30
2563
+ @agent = presence(request.params["agent"])
2564
+ profiles = profiles.select { |p| p.id == @agent } if @agent
2565
+ @stores = profiles.filter_map do |p|
2566
+ decl = Insika::FunnelDeclaration.parse(p.funnel)
2567
+ next unless decl
2568
+
2569
+ { profile: p, decl: decl, tenants: tenants_for(p.id) }
2570
+ end
2571
+ view("funnel")
2572
+ end
2573
+
2574
+ # the Follow-ups page. Per agent WITH a parsed policy, one
2575
+ # block: the pending/fired/cancelled/blocked lists (blocked rows carry the
2576
+ # reason pill), the per-arm counts, the read-only policy summary and the
2577
+ # A/B card (D10 — reads stores, mutates through bus commands only).
2578
+ def render_followups
2579
+ profiles = insika[:profile_source]&.all || []
2580
+ @agent = presence(request.params["agent"])
2581
+ profiles = profiles.select { |p| p.id == @agent } if @agent
2582
+ @stores = profiles.filter_map do |p|
2583
+ decl = Insika::FollowupPolicy.parse(p.followup)
2584
+ next unless decl
2585
+
2586
+ {
2587
+ profile: p, policy: decl,
2588
+ tenant: presence(request.params["tenant"]) || "platform",
2589
+ records: followup_records(p.id, decl),
2590
+ ab: followup_ab(p.id, decl)
2591
+ }
2592
+ end
2593
+ @has_followups = !@stores.empty?
2594
+ view("followups")
2595
+ end
2596
+
2597
+ # the Facts (wiki) page — the human gate on distilled
2598
+ # proposals. Reads ProposalStore + SessionStore directly (D7 — evidence is
2599
+ # a link, never a copy: the excerpt is read from the session at request
2600
+ # time); the mutations dispatch :resolve_proposal on the bus.
2601
+ def render_facts
2602
+ store = insika[:proposal_store]
2603
+ @pending = store ? store.pending(limit: 100) : []
2604
+ @stale = store ? store.stale(limit: 50) : []
2605
+ @recent = store ? store.resolved(limit: 20) : []
2606
+ # `?agent=` filters by the proposal's AGENT (the origin session's
2607
+ # stamp); `?store=` keeps filtering by the memory-cell scope (the
2608
+ # pre-review behavior). Both narrow the same three lists.
2609
+ @filter = presence(request.params["agent"]) || presence(request.params["store"])
2610
+ if @filter
2611
+ @pending = @pending.select { |p| scope_matches?(p, @filter) }
2612
+ @stale = @stale.select { |p| scope_matches?(p, @filter) }
2613
+ @recent = @recent.select { |p| scope_matches?(p, @filter) }
2614
+ end
2615
+ # The select: the filtered agents (profile ids) PLUS every scope a
2616
+ # proposal ever carried, so an agent with proposals and one without can
2617
+ # both be picked.
2618
+ @stores = (insika[:profile_source].ids.sort +
2619
+ (@pending + @stale + @recent).map(&:scope)).uniq.sort
2620
+ @sessions = insika[:session_store]
2621
+ view("facts")
2622
+ end
2623
+
2624
+ # A proposal matches the filter as the memory scope it carries, or as the
2625
+ # agent of the session it came from (when the session is still around).
2626
+ def scope_matches?(proposal, filter)
2627
+ return true if proposal.scope == filter
2628
+
2629
+ session = insika[:session_store]&.find(proposal.session_ref)
2630
+ session && session_agent(session) == filter
2631
+ end
2632
+
2633
+ # the Harvest page — the human gate on mined skills. Reads
2634
+ # HarvestStore/SessionStore directly (the evidence excerpt is read from the
2635
+ # session at request time — D7: ids and indexes only are stored); every
2636
+ # mutation dispatches a bus command.
2637
+ def render_harvest
2638
+ @agents = insika[:profile_source].ids.sort
2639
+ @agent = presence(request.params["agent"]) || @agents.first
2640
+ store = insika[:harvest_store]
2641
+ # EVERY list respects the agent filter (the review fix) — the page is a
2642
+ # per-store inbox, not a global one.
2643
+ @candidates = @agent && store ? store.candidates(agent_id: @agent, status: "awaiting_approval") : []
2644
+ @pending_gates = @agent && store ? store.candidates(agent_id: @agent, status: "pending").first(50) : []
2645
+ @blocked = if @agent && store
2646
+ store.candidates(agent_id: @agent, status: "gated")
2647
+ .select { |c| !c.eval_gate || !c.conversion_gate || c.conversion_gate["passed"] == false }
2648
+ .first(50)
2649
+ else
2650
+ []
2651
+ end
2652
+ @recent = @agent && store ? store.candidates(agent_id: @agent, status: "promoted").first(20) : []
2653
+ @runs = @agent && store ? store.runs_for(@agent, limit: 5) : []
2654
+ @promotions = @agent && store ? store.promotions(agent_id: @agent, limit: 50) : []
2655
+ @criterion = insika[:harvest_criterion]
2656
+ @funnel = insika[:funnel_store]
2657
+ @sessions = insika[:session_store]
2658
+ @negative = insika[:negative_list]
2659
+ @filter = presence(request.params["agent"])
2660
+ view("harvest")
2661
+ end
2662
+
2663
+ # --- Knowledge index -------------------------------------------------
2664
+
2665
+ # Master data for the Knowledge drill-down: every concept of ONE agent
2666
+ # (+ optional tenant), parsed for the list columns (type/confidence/
2667
+ # occurrences/updated_at). A record that fails to parse (corrupted by a
2668
+ # hand edit) still shows by name, blank elsewhere, rather than vanishing.
2669
+ def load_knowledge_master(agent:, tenant: nil)
2670
+ @status ||= "all"
2671
+ store = insika[:knowledge_store]
2672
+ names = agent && store ? store.names(agent, tenant: tenant) : []
2673
+ concepts = names.filter_map do |n|
2674
+ Insika::Knowledge::Concept.parse(store.get(agent, n, tenant: tenant)) ||
2675
+ { name: n, description: "", type: "", confidence: 0.0, occurrences: 0, updated_at: "", body: "" }
2676
+ end.sort_by { |c| c[:name] }
2677
+ @all_count = concepts.size
2678
+ @conflict_count = concepts.count { |c| concept_conflict?(c) }
2679
+ @concepts = @status == "conflict" ? concepts.select { |c| concept_conflict?(c) } : concepts
2680
+ end
2681
+
2682
+ def render_knowledge
2683
+ @agents = insika[:profile_source].ids.sort
2684
+ @agent = presence(request.params["agent"]) || @agents.first
2685
+ @tenant = presence(request.params["tenant"])
2686
+ @status = presence(request.params["status"]) || "all"
2687
+ load_knowledge_master(agent: @agent, tenant: @tenant)
2688
+ # Auto-open the first concept (drill-down convention), respecting the
2689
+ # active filter — the empty state only shows with nothing left to see.
2690
+ @selected = @concepts.first&.dig(:name)
2691
+ @concept_content = @selected ? concept_source(@agent, @selected, tenant: @tenant) : nil
2692
+ @selected_concept = @concept_content && Insika::Knowledge::Concept.parse(@concept_content)
2693
+ @concept_versions = @selected && insika[:knowledge_store]&.versions(@agent, @selected, tenant: @tenant)
2694
+ view("knowledge")
2695
+ end
2696
+
2697
+ # Raw content for the editor — the complete concept markdown, straight
2698
+ # from the store (unlike a skill, there is no disk fallback to
2699
+ # reconstruct: a concept only ever exists in the store).
2700
+ def concept_source(agent, name, tenant: nil)
2701
+ insika[:knowledge_store]&.get(agent, name, tenant: tenant) || new_concept_template(name)
2702
+ end
2703
+
2704
+ # An operator authoring a concept by hand starts as curated content, not
2705
+ # a claim to earn trust for — `provenance: policy`, full confidence, no
2706
+ # sources (nothing extracted it).
2707
+ def new_concept_template(name = "my-concept")
2708
+ Insika::Knowledge::Concept.render(
2709
+ name: name, description: "one sentence about what this concept says", type: "fact",
2710
+ body: "The durable claim, in your own words.",
2711
+ provenance: "policy", confidence: 1.0, sources: [], occurrences: 1,
2712
+ created_at: Time.now.utc.iso8601, updated_at: Time.now.utc.iso8601
2713
+ )
2714
+ end
2715
+
2716
+ # A concept is flagged "conflict" status by the marker its own body
2717
+ # carries (§3.5) — no extra store field, the marker IS the flag.
2718
+ def concept_conflict?(parsed)
2719
+ parsed && parsed[:body].to_s.include?(Insika::Knowledge::CONTRADICTION_HEADING)
2720
+ end
2721
+
2722
+ # The redirect/link target after a knowledge write or restore — back to
2723
+ # the concept's own detail pane when a name is known, else the index.
2724
+ def knowledge_path(agent, name, tenant)
2725
+ q = "agent=#{Rack::Utils.escape(agent.to_s)}"
2726
+ q += "&tenant=#{Rack::Utils.escape(tenant)}" if tenant
2727
+ name ? "/studio/knowledge/#{Rack::Utils.escape(name)}?#{q}" : "/studio/knowledge?#{q}"
2728
+ end
2729
+
2730
+ # The evidence excerpt for a candidate — the origin sessions' messages at
2731
+ # the stored indexes, read at request time (D7: ids and indexes only).
2732
+ #
2733
+ # An index is validated by the miner as "fits at least one origin
2734
+ # session", so it must NOT be rendered against every origin session — the
2735
+ # same index would point at different conversations (the review fix).
2736
+ # Each index renders ONE message, from the first origin session where it
2737
+ # is in range, labeled with that session.
2738
+ def candidate_excerpt(candidate)
2739
+ Array(candidate.evidence_turns).filter_map do |i|
2740
+ session = Array(candidate.origin).filter_map { |sid| @sessions&.find(sid) }
2741
+ .find { |s| s.messages && s.messages[i] }
2742
+ next unless session
2743
+
2744
+ [session.id, i, session.messages[i]["content"].to_s]
2745
+ end
2746
+ end
2747
+
2748
+ # The negative list's rejection counts per rule, read from the RUN
2749
+ # records (D4 — the counts the Studio shows are what the engine logged:
2750
+ # the run carries { rule_id => count, "ungrounded" => N, ... }).
2751
+ def negative_rule_counts
2752
+ store = insika[:harvest_store]
2753
+ return {} unless store
2754
+
2755
+ store.runs_for(@agent || "", limit: 200).each_with_object(Hash.new(0)) do |run, acc|
2756
+ Array(run.rejected).each { |rule, n| acc[rule] += n.to_i }
2757
+ end
2758
+ end
2759
+
2760
+ # The excerpt for a proposal's evidence — the transcript messages at the
2761
+ # stored indexes, read at request time (D7: ids and indexes only are
2762
+ # stored). Deep-links to the existing session page.
2763
+ def proposal_excerpt(proposal)
2764
+ session = @sessions&.find(proposal.session_ref)
2765
+ return [] unless session && session.messages
2766
+
2767
+ Array(proposal.evidence).filter_map do |i|
2768
+ session.messages[i] && session.messages[i]["content"].to_s
2769
+ end
2770
+ end
2771
+
2772
+ # The agent's records across every status (a nil store reads as none).
2773
+ def followup_records(agent_id, _decl)
2774
+ store = insika[:followup_store]
2775
+ return [] unless store
2776
+
2777
+ store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
2778
+ agent: agent_id)
2779
+ end
2780
+
2781
+ # The A/B readout per arm (D3 — a read-only fold over the same records):
2782
+ # sent = fired count in the period;
2783
+ # conversions = outcome records whose session_id is a fired record's
2784
+ # session (primary-filtered by the declaration
2785
+ # when one exists, else the first outcome per session);
2786
+ # opt-outs = contact cells flipped :revoked after a fired_at.
2787
+ # The note anchors the ruler outside the card: the A/B is the operator's;
2788
+ # this card reads the baseline.
2789
+ def followup_ab(agent_id, _decl)
2790
+ store = insika[:followup_store]
2791
+ return {} unless store
2792
+
2793
+ from = (Time.now.utc - 30 * 86_400).iso8601
2794
+ fired = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
2795
+ agent: agent_id)
2796
+ .select { |r| r.status == "fired" && r.at.to_s >= from }
2797
+ fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
2798
+ sessions = records.filter_map(&:session_id)
2799
+ acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
2800
+ opt_outs: opt_outs_since(records) }
2801
+ end
2802
+ end
2803
+
2804
+ # The conversions of the arm's fired sessions. Primary-filtered by the
2805
+ # funnel declaration when the agent declares one; else the FIRST
2806
+ # outcome per session (an outcome record is one vote per session).
2807
+ def conversions_for(agent_id, sessions)
2808
+ outcomes = insika[:outcome_store]
2809
+ return 0 unless outcomes
2810
+
2811
+ records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
2812
+ primary = primary_stage_for(agent_id)
2813
+ return records.count { |r| r.outcome == primary } if primary
2814
+
2815
+ records.group_by(&:session_id).size
2816
+ end
2817
+
2818
+ def primary_stage_for(agent_id)
2819
+ profile = insika[:profile_source]&.fetch(agent_id)
2820
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2821
+ decl && decl.primary
2822
+ end
2823
+
2824
+ # The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
2825
+ # to (same tenant), flipped :revoked after that customer's own fire. Never
2826
+ # a global revoked count — an unrelated opt-out (another tenant, another
2827
+ # customer) must not count against an arm, or the H-followup kill decision
2828
+ # reads strangers.
2829
+ def opt_outs_since(records)
2830
+ store = insika[:contact_store]
2831
+ return 0 unless store
2832
+
2833
+ cells = store.cells
2834
+ # group this arm's fires by (tenant, customer) -> the earliest fire
2835
+ records.group_by { |r| [r.tenant, r.customer] }.sum do |(tenant, customer), recs|
2836
+ fired_at = recs.filter_map { |r| parse_time(r.fired_at || r.at) }.min
2837
+ next 0 if fired_at.nil?
2838
+
2839
+ cell = cells["#{tenant}:#{customer}"]
2840
+ cell && cell["state"] == "revoked" && revoke_after?(cell, fired_at) ? 1 : 0
2841
+ end
2842
+ end
2843
+
2844
+ def revoke_after?(cell, fired_at)
2845
+ updated = parse_time(cell["updated_at"])
2846
+ updated && updated > fired_at
2847
+ end
2848
+
2849
+ def parse_time(value)
2850
+ Time.iso8601(value.to_s)
2851
+ rescue ArgumentError
2852
+ nil
2853
+ end
2854
+
2855
+ # Tenant list per agent, derived from the store cells (D7, the 0031 drill
2856
+ # pattern) — multi-tenant deployments show one card block per tenant; a
2857
+ # single-tenant deployment just "platform".
2858
+ def tenants_for(agent_id)
2859
+ cells = insika[:funnel_store]&.pairs.to_a || []
2860
+ cells.select { |c| c[:agent] == agent_id }.map { |c| c[:tenant] }
2861
+ end
2862
+
2863
+ # Sums the folded counts over the period -> { stage => count } for the view.
2864
+ # A nil store (base wiring) reads as all-zero — the page still renders the
2865
+ # declaration and the freeze form.
2866
+ def funnel_period_counts(store, tenant, agent, period)
2867
+ return {} unless store
2868
+
2869
+ today = Date.today.iso8601
2870
+ from = (Date.today - period).iso8601
2871
+ store.days(tenant: tenant, agent: agent, from: from, to: today)
2872
+ .each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
2873
+ end
2874
+
2875
+ # BudgetLedger's current counters (D6): "tokens today / this month". nil
2876
+ # store -> the view renders 0 (the empty state).
2877
+ def funnel_spend(store, tenant, agent)
2878
+ return { daily: 0, monthly: 0 } unless store
2879
+
2880
+ store.current(tenant: tenant, agent: agent)
2881
+ end
2882
+
2883
+ # the session's stage history — each outcome record of the
2884
+ # session folded to its stage by the agent's declaration (unmapped kinds
2885
+ # render the raw outcome with a muted note — the hole is visible).
2886
+ # Without a declaration the block renders the raw outcomes.
2887
+ def stage_history_for(sid)
2888
+ records = Array(insika[:outcome_store]&.all&.select { |r| r.session_id == sid })
2889
+ .sort_by { |r| r.at.to_s }
2890
+ return [] if records.empty?
2891
+
2892
+ agent = records.first.agent
2893
+ profile = insika[:profile_source]&.fetch(agent)
2894
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2895
+ {
2896
+ declaration: decl,
2897
+ rows: records.map do |r|
2898
+ stage = decl && decl.advance_on[r.outcome]
2899
+ { outcome: r.outcome, stage: stage, at: r.at, value: r.value }
2900
+ end
2901
+ }
2902
+ end
2903
+
1559
2904
  # A run's window in words. An EMPTY window means "the collector's own default" —
1560
2905
  # resolve it for the operator instead of rendering a blank.
1561
2906
  def refinement_window(window)
@@ -1641,11 +2986,46 @@ end
1641
2986
  "/studio/playground?#{query}"
1642
2987
  end
1643
2988
 
1644
- def agent_path(id, anchor = nil)
2989
+ def agent_path(id, anchor = nil, query = nil)
1645
2990
  base = "/studio/agents/#{Rack::Utils.escape(id)}"
2991
+ base += "?#{query}" if query
1646
2992
  anchor ? "#{base}##{anchor}" : base
1647
2993
  end
1648
2994
 
2995
+ # "New from template": evaluates the template (same isolated
2996
+ # eval the CLI's `insika new` never even needs, since it just copies
2997
+ # files — this is the OTHER door) and imports every pack through the
2998
+ # SAME PackImporter a hand-written pack would use — E2's byte-identical
2999
+ # promise. mcp_instances ride separately (never part of a
3000
+ # pack, McpStore is its own store) — each dispatched as its own
3001
+ # :upsert_mcp, the same Command a Studio-authored instance would use
3002
+ # (Studio never writes a store directly). -> { agent_id: }, the
3003
+ # template's primary agent (Definition#id / System#id: the first
3004
+ # declared) — the redirect target when several agents were created.
3005
+ def create_agent_from_template(name)
3006
+ built = Insika::Templates.evaluate(name)
3007
+ packs = built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
3008
+ importer = Insika::PackImporter.new(bus: insika[:command_bus], profiles: insika[:profile_source])
3009
+ packs.each { |pack| importer.import(pack) }
3010
+ declared_mcp = Array(built.mcp_instances)
3011
+ declared_mcp.each { |decl| dispatch(:upsert_mcp, decl) }
3012
+ { agent_id: built.id }
3013
+ end
3014
+
3015
+ # Deterministic avatar hue: the id's fingerprint, so agents/sessions are
3016
+ # scannable at a glance (Linear-style). CSP forbids inline styles, so the
3017
+ # hue is a class: avatar-h0..avatar-h9 in application.css.
3018
+ def avatar_class(id)
3019
+ "avatar-h#{id.to_s.bytes.sum % 10}"
3020
+ end
3021
+
3022
+ # Where a prompt POST lands back: the same file open in the drill, scrolled
3023
+ # to the prompts section. No file (a delete, say) → the agent page itself.
3024
+ def prompt_edit_path(id, file)
3025
+ name = presence(file)
3026
+ name ? "#{agent_path(id)}/prompts/#{Rack::Utils.escape(name)}#prompts" : agent_path(id, "prompts")
3027
+ end
3028
+
1649
3029
  # Serves a versioned asset from dist. `File.basename` kills path traversal; only
1650
3030
  # files that exist in the dist dir are served.
1651
3031
  def serve_asset(name)
@@ -1684,10 +3064,13 @@ end
1684
3064
  # forms: resubmitting without touching preserves the real secret in the store).
1685
3065
  def secret_sentinel = Insika::SecretMasking::SENTINEL
1686
3066
 
1687
- # An MCP instance's env (already MASKED) -> "KEY=value" text per line,
1688
- # for the textarea. Sorts by key (stable across renders).
3067
+ # An MCP instance's env/headers (already MASKED) -> "KEY=value" text per
3068
+ # line, for the textarea. Sorts by key (stable across renders).
1689
3069
  def env_lines(env)
1690
3070
  (env || {}).sort.map { |k, v| "#{k}=#{v}" }.join("\n")
1691
3071
  end
3072
+
3073
+ # An MCP instance's args (stdio argv) -> one token per line, for the textarea.
3074
+ def list_lines(list) = Array(list).join("\n")
1692
3075
  end
1693
3076
  end