insika 0.3.0 → 0.8.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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -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 +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  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 +99 -17
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +50 -19
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. 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
@@ -92,7 +98,9 @@ module Studio
92
98
  followup_store: nil, contact_store: nil,
93
99
  proposal_store: nil,
94
100
  harvest_store: nil, harvest_criterion: nil, negative_list: nil,
95
- channel_registry: nil)
101
+ channel_registry: nil,
102
+ artifact_store: nil, artifact_signing: nil,
103
+ knowledge_store: nil)
96
104
  @insika = {
97
105
  command_bus: command_bus, profile_source: profile_source,
98
106
  event_stream: event_stream, config: config,
@@ -158,15 +166,18 @@ module Studio
158
166
  # (boot-loaded, with its sha) and the negative list render the two
159
167
  # pre-registered artifacts. nil = the page renders its empty state.
160
168
  harvest_store: harvest_store, harvest_criterion: harvest_criterion,
161
- negative_list: negative_list
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
162
180
  }.freeze
163
- # "restart recommended" flag — in memory, PER PROCESS. A
164
- # config change that the runtime only re-reads at boot (e.g.: MCP instances are
165
- # wired at startup) lights the flag; restarting the process clears it
166
- # naturally (new process = `configure` runs again = flag reset).
167
- # Deliberately not put in the session: the session survives the restart (the secret
168
- # derives from the token) and the flag would stay stuck on.
169
- @restart_needed = false
170
181
  secret = session_secret || derive_secret(config[:admin_token])
171
182
  plugin :sessions, key: "insika.studio", secret: secret,
172
183
  max_seconds: SESSION_MAX_AGE, same_site: :lax
@@ -185,11 +196,6 @@ module Studio
185
196
  def derive_secret(admin_token)
186
197
  Digest::SHA512.hexdigest("insika-studio-session-v1:#{admin_token}")
187
198
  end
188
-
189
- # --- Restart recommended — per-process state -------------------
190
- def restart_needed? = @restart_needed == true
191
- def mark_restart_needed! = (@restart_needed = true)
192
- def clear_restart_needed! = (@restart_needed = false)
193
199
  end
194
200
 
195
201
  route do |r|
@@ -229,6 +235,18 @@ module Studio
229
235
  end
230
236
  end
231
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
+
232
250
  # --- FAIL-CLOSED: from here down requires an authenticated session ----
233
251
  r.redirect("/studio/login") unless authenticated?
234
252
 
@@ -238,14 +256,6 @@ module Studio
238
256
  r.redirect("/studio/login")
239
257
  end
240
258
 
241
- # Dismisses the "restart recommended" banner without restarting (the operator
242
- # acknowledges and moves on). A real restart clears the flag on its own.
243
- r.post "restart-ack" do
244
- check_csrf!
245
- self.class.clear_restart_needed!
246
- r.redirect(safe_back(r.params["back"]))
247
- end
248
-
249
259
  # GET /studio/events — the live transcript's SSE, on the STUDIO's own auth.
250
260
  # `EventSource` cannot send an Authorization header, so the browser used to read
251
261
  # the server's `/v1/events` directly — which is why that route had to stay open to
@@ -282,6 +292,9 @@ module Studio
282
292
  # whole grid (a store scan per card would be n scans). Series live
283
293
  # on the agent detail — one agent's periods, not n charts here.
284
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
285
298
  view("agents")
286
299
  end
287
300
 
@@ -301,6 +314,19 @@ module Studio
301
314
  end
302
315
  end
303
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
+
304
330
  # /studio/agents/:id — an agent's authoring page.
305
331
  r.on String do |id|
306
332
  id = utf8(id)
@@ -709,6 +735,26 @@ module Studio
709
735
  r.redirect("/studio/settings?s=llm")
710
736
  end
711
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
712
758
  end
713
759
 
714
760
  # --- MCP: instances with masked credentials ------------------
@@ -717,20 +763,56 @@ module Studio
717
763
  r.get { render_mcp }
718
764
  r.post do
719
765
  check_csrf!
720
- with_flash("MCP instance saved.") do
721
- dispatch(:upsert_mcp, mcp_patch(r))
722
- # MCP servers are wired at runtime boot; the new instance
723
- # only takes effect after a restart. Lights the "restart" banner.
724
- 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)
725
771
  end
726
- r.redirect("/studio/mcp")
772
+ r.redirect(mcp_path(result ? name : presence(r.params["name"])))
727
773
  end
728
774
  end
729
775
  r.post "delete" do
730
776
  check_csrf!
731
777
  with_flash("MCP instance removed.") do
732
778
  dispatch(:delete_mcp, { name: presence(r.params["name"]) })
733
- 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
734
816
  end
735
817
  r.redirect("/studio/mcp")
736
818
  end
@@ -777,6 +859,10 @@ module Studio
777
859
  end
778
860
 
779
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.
780
866
  r.on "sessions" do
781
867
  r.on String do |sid|
782
868
  sid = utf8(sid)
@@ -784,6 +870,8 @@ module Studio
784
870
  @session = insika[:session_store]&.find(sid)
785
871
  next_404 unless @session
786
872
 
873
+ # master column: recent conversations, current one active
874
+ @sessions = recent_sessions(limit: 60)
787
875
  # Session's tool-call trace (debug): grouped by turn in the view.
788
876
  @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
789
877
  .group_by { |t| t["turn"] }
@@ -792,7 +880,11 @@ module Studio
792
880
  # the stage history — the session's outcomes folded
793
881
  # into the agent's declared stages (unmapped kinds render raw).
794
882
  @stage_history = stage_history_for(sid)
795
- view("session")
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
796
888
  end
797
889
  end
798
890
  end
@@ -1051,6 +1143,77 @@ end
1051
1143
  end
1052
1144
  end
1053
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
+
1054
1217
  # --- Facts: the human gate on distilled proposals --------
1055
1218
  # Reads ProposalStore directly (the Studio's constitutional split — reads
1056
1219
  # hit the stores, mutations go through the bus); the ONLY mutation — the
@@ -1147,6 +1310,26 @@ end
1147
1310
  end
1148
1311
  end
1149
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
+
1150
1333
  # Playground: sends `send_message` (the SAME Command as the API) and streams the
1151
1334
  # response live through the `live-transcript` island (SSE from /studio/events).
1152
1335
  r.on "playground" do
@@ -1234,12 +1417,18 @@ end
1234
1417
  # the Follow-ups page — scheduled, fired, blocked and
1235
1418
  # cancelled records per agent + the per-arm A/B readout card.
1236
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],
1237
1423
  # the Facts (wiki) page — the human gate on distilled
1238
1424
  # proposals (approve/reject/dismiss, the latch, the CAS re-present).
1239
1425
  ["Facts", "/studio/facts", :facts],
1240
1426
  # the Harvest page — the human gate on mined skills
1241
1427
  # (gate, promote, reject, rollback; the append-only promotion log).
1242
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],
1243
1432
  ["Tasks", "/studio/tasks", :tasks],
1244
1433
  ["Approvals", "/studio/approvals", :approvals],
1245
1434
  ["Refinement", "/studio/refinement", :refinement],
@@ -1253,6 +1442,16 @@ end
1253
1442
 
1254
1443
  def authenticated? = session["auth"] == true
1255
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
+
1256
1455
  # Is the given nav href the current page? Robust to whether request.path
1257
1456
  # carries the "/studio" mount prefix (it does under serve_real/config.ru, it
1258
1457
  # doesn't in specs) — strip it from both sides, then exact- or prefix-match
@@ -1275,9 +1474,7 @@ end
1275
1474
  # styles. Memoized on the request instance so header and <meta> agree.
1276
1475
  def csp_nonce = (@csp_nonce ||= (session["csp_nonce"] ||= SecureRandom.base64(16)))
1277
1476
 
1278
- # --- Polish: theme, health chip, restart banner ----------------
1279
-
1280
- def restart_needed? = self.class.restart_needed?
1477
+ # --- Polish: theme, health chip -------------------------------
1281
1478
 
1282
1479
  # Environment label for the sidebar identity chip — reflects the REAL runtime
1283
1480
  # env (INSIKA_ENV → RACK_ENV → "local") so an operator always knows which box
@@ -1342,6 +1539,18 @@ end
1342
1539
  content.to_s
1343
1540
  end
1344
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
+
1345
1554
  # An activation label from the context trace, as the operator reads it:
1346
1555
  # "gift-concierge · trigger:presente". The REASON is the point of the card — a
1347
1556
  # bare list of names answers "was something injected", never "which one did I
@@ -1413,6 +1622,18 @@ end
1413
1622
 
1414
1623
  def render_agent_detail
1415
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
1416
1637
  @config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
1417
1638
  store = insika[:agent_file_store]
1418
1639
  @prompt_files = Array(@agent.prompt_files).map do |name|
@@ -1442,7 +1663,18 @@ end
1442
1663
  # the per-agent cache-hit series (nil store -> the view's
1443
1664
  # empty state; same guard as the tool trace).
1444
1665
  @cache_series = insika[:cache_series_store]&.for_agent(id) || []
1445
- view("agent_detail")
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
1446
1678
  end
1447
1679
 
1448
1680
  # A generation param off the profile (string-keyed by AgentProfile.build). Used
@@ -1483,7 +1715,7 @@ end
1483
1715
  rows = options.map do |value, label|
1484
1716
  %(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
1485
1717
  end.join
1486
- %(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" onchange="this.form.submit()">#{rows}</select></label></form>)
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>)
1487
1719
  end
1488
1720
 
1489
1721
  # The sessions of one agent — the session stamps its agent in
@@ -1695,7 +1927,13 @@ end
1695
1927
  # default to the first agent so the pane is useful on landing.
1696
1928
  sel = request.params["a"]
1697
1929
  @sel_agent = (sel && @agents.find { |a| a.id == sel }) || @agents.first
1698
- 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
1699
1937
  end
1700
1938
 
1701
1939
  # nil = all; otherwise the list. Pre-checks the checkboxes per agent.
@@ -1825,7 +2063,8 @@ end
1825
2063
  # data the Studio already reads (one scan of the session store); no new
1826
2064
  # metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
1827
2065
  # `?agent=` narrows every number to one agent (the sessions stamp their
1828
- # author in `vars["agent"]`).
2066
+ # author in `vars["agent"]`). The live layer (live_home_controller) only
2067
+ # repaints what this renders — it never computes its own baseline.
1829
2068
  def render_home
1830
2069
  ps = insika[:profile_source]
1831
2070
  @agent = presence(request.params["agent"])
@@ -1839,11 +2078,30 @@ end
1839
2078
  "providers" => insika[:llm_provider_store] ? insika[:llm_provider_store].all.size : 0,
1840
2079
  "MCP servers" => insika[:mcp_store] ? insika[:mcp_store].all.size : 0
1841
2080
  }
1842
- now = Time.now
2081
+ # UTC, deliberately: sessions stamp `updated_at` with `Time.now.utc.iso8601`,
2082
+ # and every bucket below is keyed by a calendar part (date, hour) of that
2083
+ # stamp. A LOCAL `now` mixes two clocks — on a UTC-3 host, from 21:00 local
2084
+ # onward "today" is already tomorrow in UTC, so the day buckets stopped
2085
+ # matching and the 24h floor was built three hours in the future, silently
2086
+ # emptying both charts. Instant comparisons (`cutoff`) never had the bug;
2087
+ # calendar arithmetic did.
2088
+ now = Time.now.utc
1843
2089
  cutoff = now - (5 * 60)
1844
2090
  @active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
1845
2091
  @recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
1846
2092
  @activity = activity_by_day(sessions, days: 14, now: now)
2093
+ # 24h sparkline: conversations touched per hour, oldest
2094
+ # first — the same session scan, bucketed finer.
2095
+ @activity_24h = activity_by_hour(sessions, hours: 24, now: now)
2096
+ # Trend affordances on the traffic stat cards: today vs yesterday from
2097
+ # the 14-day series (config counts — agents/skills/tools/providers —
2098
+ # have no daily shape and honestly show no trend).
2099
+ # `@activity` is OLDEST FIRST and ends at today, so the last pair reads
2100
+ # [yesterday, today]. Destructured the other way round it reported a
2101
+ # first-conversation-of-the-day as "−1", every day.
2102
+ yesterday, today = @activity.last(2).map(&:last)
2103
+ @conv_trend = today.to_i - yesterday.to_i
2104
+ @msg_trend = message_delta(sessions, now)
1847
2105
  @persistence = insika.dig(:config, :persistence)
1848
2106
  view("home")
1849
2107
  end
@@ -1865,16 +2123,54 @@ end
1865
2123
  end
1866
2124
 
1867
2125
  # [[Date, count], …] — one bucket per day over the window, most-recent last.
2126
+ # `now` is UTC (render_home) and so is every `t` — see #utc_time. Both sides
2127
+ # of the bucket key must be read off the same clock or the join silently
2128
+ # misses.
1868
2129
  def activity_by_day(sessions, days:, now:)
1869
2130
  today = now.to_date
1870
2131
  buckets = Hash.new(0)
1871
2132
  sessions.each do |s|
1872
- t = parse_time(s.updated_at) or next
2133
+ t = utc_time(s.updated_at) or next
1873
2134
  buckets[t.to_date] += 1
1874
2135
  end
1875
2136
  (0...days).to_a.reverse.map { |i| d = today - i; [d, buckets[d]] }
1876
2137
  end
1877
2138
 
2139
+ # [[hour_start, count], …] — one bucket per hour over the last `hours`,
2140
+ # oldest first, each bucket labeled by its starting hour. Feeds the 24h
2141
+ # sparkline; a session counts in the hour it was last
2142
+ # touched — the same reading "conversations touched" the daily chart uses.
2143
+ def activity_by_hour(sessions, hours:, now:)
2144
+ floor = Time.utc(now.year, now.month, now.day, now.hour) - (hours - 1) * 3600
2145
+ buckets = Hash.new(0)
2146
+ sessions.each do |s|
2147
+ t = utc_time(s.updated_at) or next
2148
+ h = Time.utc(t.year, t.month, t.day, t.hour)
2149
+ buckets[h] += 1 if h >= floor
2150
+ end
2151
+ (0...hours).map { |i| [floor + i * 3600, buckets[floor + i * 3600]] }
2152
+ end
2153
+
2154
+ # Messages in sessions touched today vs yesterday (the delta the messages
2155
+ # stat card shows). A conversation's whole message count lands in the day
2156
+ # it was last updated — coarse, but it is the same data the card counts.
2157
+ def message_delta(sessions, now)
2158
+ today = now.to_date
2159
+ sum = ->(date) do
2160
+ sessions.sum { |s| (t = utc_time(s.updated_at)) && t.to_date == date ? Array(s.messages).size : 0 }
2161
+ end
2162
+ sum.call(today) - sum.call(today - 1)
2163
+ end
2164
+
2165
+ # A stamp read for its CALENDAR parts, always in UTC. `Time.parse` honours
2166
+ # whatever offset the string carries — ours are `Z`, but a record written by
2167
+ # anything else would otherwise bucket by its own zone. `getutc`, not `utc`:
2168
+ # the latter mutates the receiver.
2169
+ def utc_time(value)
2170
+ t = parse_time(value)
2171
+ t&.getutc
2172
+ end
2173
+
1878
2174
  # --- History -------------------------------------------------------------
1879
2175
 
1880
2176
  # Recent conversations (all agents — the Session doesn't stamp the agent that
@@ -1904,21 +2200,24 @@ end
1904
2200
 
1905
2201
  def session_preview(session)
1906
2202
  last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
1907
- last && last["content"].to_s
2203
+ # Structured content previews as JSON (message_content), never #inspect —
2204
+ # a hashrocket in a filter-text attribute is exactly the leak this guards.
2205
+ last && message_content(last["content"])
1908
2206
  end
1909
2207
 
1910
2208
  # --- Settings + LLM providers ----------------------------------
1911
2209
 
1912
2210
  # The agent config tab's groups, in sidebar order. The view renders the
1913
2211
  # same keys; a bogus ?cfg= falls back to the first group.
1914
- CONFIG_SECTIONS = %w[model guardrails grounding funnel followups distill harvest refinement budget_rel routing advanced].freeze
2212
+ CONFIG_SECTIONS = %w[model guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
1915
2213
 
1916
- SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
2214
+ SETTINGS_SECTIONS = %w[general models edge evals llm demo].freeze
1917
2215
  def render_settings
1918
2216
  store = insika[:settings_store]
1919
2217
  @settings = store ? store.get : Insika::SettingsStore::DEFAULTS
1920
2218
  @providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
1921
2219
  @section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
2220
+ @persistence = insika[:config][:persistence].to_s
1922
2221
  view("settings")
1923
2222
  end
1924
2223
 
@@ -1930,12 +2229,73 @@ end
1930
2229
  # --- MCP -------------------------------------------------------
1931
2230
 
1932
2231
  def render_mcp
1933
- @instances = insika[:mcp_store] ? insika[:mcp_store].all : []
1934
- view("mcp")
2232
+ @instances = insika[:mcp_store] ? insika[:mcp_store].all.sort_by { |m| m["name"].to_s } : []
2233
+ # Miller columns: ?i= selects the instance whose editor
2234
+ # fills the detail pane. Absent = the first instance (the pane is useful
2235
+ # on landing, same convention as the tools matrix); "?i=" (empty) = the
2236
+ # empty state, which hosts the new/import forms — the master's "+ new"
2237
+ # link points there. A Turbo-Frame request renders the pane alone.
2238
+ raw = request.params["i"]
2239
+ @selected_mcp = if raw.nil?
2240
+ @instances.first
2241
+ else
2242
+ @instances.find { |m| m["name"] == raw }
2243
+ end
2244
+ if turbo_frame?("mcp-detail")
2245
+ render("mcp", locals: { frame_only: true }, layout: false)
2246
+ else
2247
+ view("mcp", locals: { frame_only: false })
2248
+ end
2249
+ end
2250
+
2251
+ # Where an MCP write redirects back to: the miller shell with the touched
2252
+ # instance still selected, so a frame submit lands on the saved editor
2253
+ # rather than the empty state. A deleted instance falls back to the shell.
2254
+ def mcp_path(name = nil)
2255
+ return "/studio/mcp" if Insika::Coercion.blank?(name)
2256
+
2257
+ "/studio/mcp?i=#{Rack::Utils.escape(name)}"
1935
2258
  end
1936
2259
 
1937
2260
  # mcp_patch moved to Studio::Forms.
1938
2261
 
2262
+ # Bulk `mcpServers` JSON import, routed through the bus:
2263
+ # gives Insika::McpJson.import the `#upsert` duck-type it expects while
2264
+ # every actual write still goes through :upsert_mcp — the adapter's
2265
+ # block closes over `self` (the App instance), so `dispatch` resolves
2266
+ # normally regardless of its own visibility. -> [Hash] masked records.
2267
+ def import_mcp_json(json)
2268
+ dispatcher = method(:dispatch)
2269
+ adapter = Object.new
2270
+ adapter.define_singleton_method(:upsert) { |attrs| dispatcher.call(:upsert_mcp, attrs) }
2271
+ Insika::McpJson.import(json, mcp_store: adapter)
2272
+ end
2273
+
2274
+ # Connection/discovery status chip for an ENABLED instance -
2275
+ # durable state read from the record, not a live probe: "N
2276
+ # tool(s)" means the last refresh cached that many, not that the server
2277
+ # is reachable RIGHT NOW (only "Test connection" tells you that; a failed
2278
+ # attempt surfaces as a flash, not a chip — McpStore holds no "last
2279
+ # error" field to persist). -> [label, pill_css_class].
2280
+ def mcp_status(record)
2281
+ if record["transport"] == "stdio" && !Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO"))
2282
+ return ["stdio disabled", "err"]
2283
+ end
2284
+
2285
+ tools = Array(record["tools_cache"])
2286
+ return ["untested", ""] if tools.empty?
2287
+
2288
+ ["#{tools.size} tool(s)", "ok"]
2289
+ end
2290
+
2291
+ # Haystack for the /mcp list-filter box: name, transport, on/off, and the
2292
+ # same status label the pill shows — so typing "stdio", "off", "err" or
2293
+ # "untested" narrows the list, not just a name search.
2294
+ def mcp_filter_text(record)
2295
+ status_label, = mcp_status(record)
2296
+ [record["name"], record["transport"], record["enabled"] ? "on enabled" : "off disabled", status_label].join(" ")
2297
+ end
2298
+
1939
2299
  # --- System-files ----------------------------------------------
1940
2300
 
1941
2301
  def render_system_files
@@ -2053,8 +2413,15 @@ end
2053
2413
 
2054
2414
  # The case as the YAML an operator edits — the same shape `evals/golden/**` holds,
2055
2415
  # so there is one format to learn and a pull request can review what was authored.
2416
+ # A SIMULATED case carries `persona:` instead of `turns:` — the two
2417
+ # shapes are the same YAML, and dropping either key would silently change what
2418
+ # the case tests.
2056
2419
  def golden_yaml(golden)
2057
- h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
2420
+ h = if golden.simulated?
2421
+ { "id" => golden.id, "agent" => golden.agent, "persona" => golden.persona.to_h }
2422
+ else
2423
+ { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
2424
+ end
2058
2425
  h["requires"] = golden.requires unless golden.requires.empty? # dropping it would un-skip the case
2059
2426
  h["reference"] = golden.reference unless golden.reference.empty? # …and this would un-compare it
2060
2427
  YAML.dump(h.merge("expect" => golden.expect))
@@ -2119,6 +2486,93 @@ end
2119
2486
  view("parity")
2120
2487
  end
2121
2488
 
2489
+ # --- Artifacts ---------------------------------------------
2490
+
2491
+ # The restrictive CSP for artifact CONTENT (both the authenticated and the
2492
+ # signed route): artifact content is LLM output — untrusted. No script, no
2493
+ # external fetch, no forms; inline styles (the report's own styling) and
2494
+ # data: images (inline SVG) are the two things a report legitimately needs.
2495
+ ARTIFACT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
2496
+
2497
+ def artifact_store = insika[:artifact_store]
2498
+
2499
+ # -> { key:, ttl:, base_url: } | nil (no signing configured -> no signed surface).
2500
+ def artifact_signing = insika[:artifact_signing]
2501
+
2502
+ def artifact_tenant = presence(request.params["tenant"]) || "platform"
2503
+
2504
+ # The Artifacts page: per-agent list (the listing IS the history — newest
2505
+ # first). Reads the store directly; the only mutation (delete) dispatches
2506
+ # :delete_artifact on the bus.
2507
+ def render_artifacts
2508
+ @agents = insika[:profile_source].ids.sort
2509
+ @agent = presence(request.params["agent"])
2510
+ @signed_available = !artifact_signing.nil? && artifact_signing[:key].to_s.length.positive?
2511
+ # #all, not #for_agent/#for_tenant: the Studio doesn't reliably know
2512
+ # which tenant string an artifact was bound under (Playground turns
2513
+ # bind tenant=agent; a plain single-tenant API turn binds "platform") —
2514
+ # see ArtifactStore#all. Filtering by the record's own `agent` field
2515
+ # sidesteps the guess.
2516
+ @artifacts = artifact_store&.all(agent: @agent) || []
2517
+ view("artifacts")
2518
+ end
2519
+
2520
+ # The preview page: metadata + the artifact in a SANDBOXED iframe (no
2521
+ # allow-scripts / allow-same-origin / allow-forms — on top of the content
2522
+ # route's own restrictive CSP).
2523
+ def render_artifact(id)
2524
+ record = artifact_store&.find(id)
2525
+ return next_404 if record.nil?
2526
+
2527
+ @artifact = record
2528
+ @content_url = "/studio/artifacts/#{Rack::Utils.escape_path(id)}/content"
2529
+ @signed_url = signed_artifact_url(record.id)
2530
+ view("artifact")
2531
+ end
2532
+
2533
+ # The authenticated content route: raw content + the restrictive CSP. The
2534
+ # iframe (same-origin) and the channel-navigated operator read here.
2535
+ def serve_artifact_content(id)
2536
+ record = artifact_store&.find(id)
2537
+ return next_404 if record.nil?
2538
+
2539
+ serve_artifact_bytes(record)
2540
+ end
2541
+
2542
+ # The signed route: verifies the HMAC over (id, expiry) in constant time,
2543
+ # serves only what verifies AND is unexpired. Bad/expired/absent-key ->
2544
+ # 404 (never 403 — no oracle).
2545
+ def serve_signed_artifact(id, exp, sig)
2546
+ signing = artifact_signing
2547
+ key = signing && signing[:key].to_s
2548
+ return next_404 if key.to_s.empty?
2549
+
2550
+ return next_404 unless Insika::ArtifactSigning.valid?(id: id, token: sig, key: key, exp: exp)
2551
+
2552
+ record = artifact_store&.find(id)
2553
+ return next_404 if record.nil?
2554
+
2555
+ serve_artifact_bytes(record)
2556
+ end
2557
+
2558
+ # -> the signed sharing URL | nil (no key -> no signed surface).
2559
+ def signed_artifact_url(id)
2560
+ signing = artifact_signing
2561
+ return nil unless signing && signing[:key] && signing[:key].to_s.length.positive?
2562
+
2563
+ Insika::ArtifactSigning.url_for(id: id, base: signing[:base_url],
2564
+ key: signing[:key], ttl: signing[:ttl])
2565
+ end
2566
+
2567
+ def serve_artifact_bytes(record)
2568
+ response.headers["content-type"] = "#{record.mime}; charset=utf-8"
2569
+ response.headers["Content-Security-Policy"] = ARTIFACT_CSP
2570
+ response.headers["X-Content-Type-Options"] = "nosniff"
2571
+ response.headers["Cache-Control"] = "no-store"
2572
+ response.write(record.content)
2573
+ request.halt
2574
+ end
2575
+
2122
2576
  # --- Funnel ------------------------------------------------
2123
2577
 
2124
2578
  # One card per STORE (agent) that declares a funnel, each with a block per
@@ -2228,6 +2682,73 @@ end
2228
2682
  view("harvest")
2229
2683
  end
2230
2684
 
2685
+ # --- Knowledge index -------------------------------------------------
2686
+
2687
+ # Master data for the Knowledge drill-down: every concept of ONE agent
2688
+ # (+ optional tenant), parsed for the list columns (type/confidence/
2689
+ # occurrences/updated_at). A record that fails to parse (corrupted by a
2690
+ # hand edit) still shows by name, blank elsewhere, rather than vanishing.
2691
+ def load_knowledge_master(agent:, tenant: nil)
2692
+ @status ||= "all"
2693
+ store = insika[:knowledge_store]
2694
+ names = agent && store ? store.names(agent, tenant: tenant) : []
2695
+ concepts = names.filter_map do |n|
2696
+ Insika::Knowledge::Concept.parse(store.get(agent, n, tenant: tenant)) ||
2697
+ { name: n, description: "", type: "", confidence: 0.0, occurrences: 0, updated_at: "", body: "" }
2698
+ end.sort_by { |c| c[:name] }
2699
+ @all_count = concepts.size
2700
+ @conflict_count = concepts.count { |c| concept_conflict?(c) }
2701
+ @concepts = @status == "conflict" ? concepts.select { |c| concept_conflict?(c) } : concepts
2702
+ end
2703
+
2704
+ def render_knowledge
2705
+ @agents = insika[:profile_source].ids.sort
2706
+ @agent = presence(request.params["agent"]) || @agents.first
2707
+ @tenant = presence(request.params["tenant"])
2708
+ @status = presence(request.params["status"]) || "all"
2709
+ load_knowledge_master(agent: @agent, tenant: @tenant)
2710
+ # Auto-open the first concept (drill-down convention), respecting the
2711
+ # active filter — the empty state only shows with nothing left to see.
2712
+ @selected = @concepts.first&.dig(:name)
2713
+ @concept_content = @selected ? concept_source(@agent, @selected, tenant: @tenant) : nil
2714
+ @selected_concept = @concept_content && Insika::Knowledge::Concept.parse(@concept_content)
2715
+ @concept_versions = @selected && insika[:knowledge_store]&.versions(@agent, @selected, tenant: @tenant)
2716
+ view("knowledge")
2717
+ end
2718
+
2719
+ # Raw content for the editor — the complete concept markdown, straight
2720
+ # from the store (unlike a skill, there is no disk fallback to
2721
+ # reconstruct: a concept only ever exists in the store).
2722
+ def concept_source(agent, name, tenant: nil)
2723
+ insika[:knowledge_store]&.get(agent, name, tenant: tenant) || new_concept_template(name)
2724
+ end
2725
+
2726
+ # An operator authoring a concept by hand starts as curated content, not
2727
+ # a claim to earn trust for — `provenance: policy`, full confidence, no
2728
+ # sources (nothing extracted it).
2729
+ def new_concept_template(name = "my-concept")
2730
+ Insika::Knowledge::Concept.render(
2731
+ name: name, description: "one sentence about what this concept says", type: "fact",
2732
+ body: "The durable claim, in your own words.",
2733
+ provenance: "policy", confidence: 1.0, sources: [], occurrences: 1,
2734
+ created_at: Time.now.utc.iso8601, updated_at: Time.now.utc.iso8601
2735
+ )
2736
+ end
2737
+
2738
+ # A concept is flagged "conflict" status by the marker its own body
2739
+ # carries (§3.5) — no extra store field, the marker IS the flag.
2740
+ def concept_conflict?(parsed)
2741
+ parsed && parsed[:body].to_s.include?(Insika::Knowledge::CONTRADICTION_HEADING)
2742
+ end
2743
+
2744
+ # The redirect/link target after a knowledge write or restore — back to
2745
+ # the concept's own detail pane when a name is known, else the index.
2746
+ def knowledge_path(agent, name, tenant)
2747
+ q = "agent=#{Rack::Utils.escape(agent.to_s)}"
2748
+ q += "&tenant=#{Rack::Utils.escape(tenant)}" if tenant
2749
+ name ? "/studio/knowledge/#{Rack::Utils.escape(name)}?#{q}" : "/studio/knowledge?#{q}"
2750
+ end
2751
+
2231
2752
  # The evidence excerpt for a candidate — the origin sessions' messages at
2232
2753
  # the stored indexes, read at request time (D7: ids and indexes only).
2233
2754
  #
@@ -2493,6 +3014,26 @@ end
2493
3014
  anchor ? "#{base}##{anchor}" : base
2494
3015
  end
2495
3016
 
3017
+ # "New from template": evaluates the template (same isolated
3018
+ # eval the CLI's `insika new` never even needs, since it just copies
3019
+ # files — this is the OTHER door) and imports every pack through the
3020
+ # SAME PackImporter a hand-written pack would use — E2's byte-identical
3021
+ # promise. mcp_instances ride separately (never part of a
3022
+ # pack, McpStore is its own store) — each dispatched as its own
3023
+ # :upsert_mcp, the same Command a Studio-authored instance would use
3024
+ # (Studio never writes a store directly). -> { agent_id: }, the
3025
+ # template's primary agent (Definition#id / System#id: the first
3026
+ # declared) — the redirect target when several agents were created.
3027
+ def create_agent_from_template(name)
3028
+ built = Insika::Templates.evaluate(name)
3029
+ packs = built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
3030
+ importer = Insika::PackImporter.new(bus: insika[:command_bus], profiles: insika[:profile_source])
3031
+ packs.each { |pack| importer.import(pack) }
3032
+ declared_mcp = Array(built.mcp_instances)
3033
+ declared_mcp.each { |decl| dispatch(:upsert_mcp, decl) }
3034
+ { agent_id: built.id }
3035
+ end
3036
+
2496
3037
  # Deterministic avatar hue: the id's fingerprint, so agents/sessions are
2497
3038
  # scannable at a glance (Linear-style). CSP forbids inline styles, so the
2498
3039
  # hue is a class: avatar-h0..avatar-h9 in application.css.
@@ -2545,10 +3086,13 @@ end
2545
3086
  # forms: resubmitting without touching preserves the real secret in the store).
2546
3087
  def secret_sentinel = Insika::SecretMasking::SENTINEL
2547
3088
 
2548
- # An MCP instance's env (already MASKED) -> "KEY=value" text per line,
2549
- # for the textarea. Sorts by key (stable across renders).
3089
+ # An MCP instance's env/headers (already MASKED) -> "KEY=value" text per
3090
+ # line, for the textarea. Sorts by key (stable across renders).
2550
3091
  def env_lines(env)
2551
3092
  (env || {}).sort.map { |k, v| "#{k}=#{v}" }.join("\n")
2552
3093
  end
3094
+
3095
+ # An MCP instance's args (stdio argv) -> one token per line, for the textarea.
3096
+ def list_lines(list) = Array(list).join("\n")
2553
3097
  end
2554
3098
  end