insika 0.3.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  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 +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  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 +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  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 +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -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 +73 -16
  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 +22 -2
  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/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. 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"])
@@ -1844,6 +2083,15 @@ end
1844
2083
  @active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
1845
2084
  @recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
1846
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)
1847
2095
  @persistence = insika.dig(:config, :persistence)
1848
2096
  view("home")
1849
2097
  end
@@ -1875,6 +2123,32 @@ end
1875
2123
  (0...days).to_a.reverse.map { |i| d = today - i; [d, buckets[d]] }
1876
2124
  end
1877
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
+
1878
2152
  # --- History -------------------------------------------------------------
1879
2153
 
1880
2154
  # Recent conversations (all agents — the Session doesn't stamp the agent that
@@ -1904,21 +2178,24 @@ end
1904
2178
 
1905
2179
  def session_preview(session)
1906
2180
  last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
1907
- 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"])
1908
2184
  end
1909
2185
 
1910
2186
  # --- Settings + LLM providers ----------------------------------
1911
2187
 
1912
2188
  # The agent config tab's groups, in sidebar order. The view renders the
1913
2189
  # 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
2190
+ CONFIG_SECTIONS = %w[model guardrails grounding funnel followups schedules distill harvest refinement budget_rel routing advanced].freeze
1915
2191
 
1916
- SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
2192
+ SETTINGS_SECTIONS = %w[general models edge evals llm demo].freeze
1917
2193
  def render_settings
1918
2194
  store = insika[:settings_store]
1919
2195
  @settings = store ? store.get : Insika::SettingsStore::DEFAULTS
1920
2196
  @providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
1921
2197
  @section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
2198
+ @persistence = insika[:config][:persistence].to_s
1922
2199
  view("settings")
1923
2200
  end
1924
2201
 
@@ -1930,12 +2207,73 @@ end
1930
2207
  # --- MCP -------------------------------------------------------
1931
2208
 
1932
2209
  def render_mcp
1933
- @instances = insika[:mcp_store] ? insika[:mcp_store].all : []
1934
- 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)}"
1935
2236
  end
1936
2237
 
1937
2238
  # mcp_patch moved to Studio::Forms.
1938
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
+
1939
2277
  # --- System-files ----------------------------------------------
1940
2278
 
1941
2279
  def render_system_files
@@ -2053,8 +2391,15 @@ end
2053
2391
 
2054
2392
  # The case as the YAML an operator edits — the same shape `evals/golden/**` holds,
2055
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.
2056
2397
  def golden_yaml(golden)
2057
- 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
2058
2403
  h["requires"] = golden.requires unless golden.requires.empty? # dropping it would un-skip the case
2059
2404
  h["reference"] = golden.reference unless golden.reference.empty? # …and this would un-compare it
2060
2405
  YAML.dump(h.merge("expect" => golden.expect))
@@ -2119,6 +2464,93 @@ end
2119
2464
  view("parity")
2120
2465
  end
2121
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
+
2122
2554
  # --- Funnel ------------------------------------------------
2123
2555
 
2124
2556
  # One card per STORE (agent) that declares a funnel, each with a block per
@@ -2228,6 +2660,73 @@ end
2228
2660
  view("harvest")
2229
2661
  end
2230
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
+
2231
2730
  # The evidence excerpt for a candidate — the origin sessions' messages at
2232
2731
  # the stored indexes, read at request time (D7: ids and indexes only).
2233
2732
  #
@@ -2493,6 +2992,26 @@ end
2493
2992
  anchor ? "#{base}##{anchor}" : base
2494
2993
  end
2495
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
+
2496
3015
  # Deterministic avatar hue: the id's fingerprint, so agents/sessions are
2497
3016
  # scannable at a glance (Linear-style). CSP forbids inline styles, so the
2498
3017
  # hue is a class: avatar-h0..avatar-h9 in application.css.
@@ -2545,10 +3064,13 @@ end
2545
3064
  # forms: resubmitting without touching preserves the real secret in the store).
2546
3065
  def secret_sentinel = Insika::SecretMasking::SENTINEL
2547
3066
 
2548
- # An MCP instance's env (already MASKED) -> "KEY=value" text per line,
2549
- # 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).
2550
3069
  def env_lines(env)
2551
3070
  (env || {}).sort.map { |k, v| "#{k}=#{v}" }.join("\n")
2552
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")
2553
3075
  end
2554
3076
  end