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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
data/lib/insika/studio/app.rb
CHANGED
|
@@ -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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|