insika 0.0.1 → 0.2.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 +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,1693 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "roda"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "rack/utils"
|
|
7
|
+
require "json"
|
|
8
|
+
require "time"
|
|
9
|
+
require "date"
|
|
10
|
+
require "yaml"
|
|
11
|
+
require_relative "forms"
|
|
12
|
+
require_relative "nav_icons"
|
|
13
|
+
# The live transcript's SSE body. Shared with the server on purpose: one wire format
|
|
14
|
+
# (Event#to_h) for the same EventStream, whichever door the client came through.
|
|
15
|
+
require_relative "../server/sse_body"
|
|
16
|
+
|
|
17
|
+
module Studio
|
|
18
|
+
# Insika Studio — the server-rendered management UI, replacing
|
|
19
|
+
# OpenClaw's agent-studio. FRAMEWORK AT THE EDGE: it's a separate
|
|
20
|
+
# Roda app, mounted under `/studio`; `lib/insika` and `server/` do NOT gain
|
|
21
|
+
# a Roda dependency. It talks to the runtime through the SAME surface as the API:
|
|
22
|
+
# dispatches Commands on the CommandBus and READS profiles/stores — never writes to a store
|
|
23
|
+
# directly (the transport's constitutional rule).
|
|
24
|
+
#
|
|
25
|
+
# SESSION/cookie auth: login compares the token in constant time against
|
|
26
|
+
# `ADMIN_TOKEN`, sets an httpOnly SameSite=Lax cookie and protects `/studio/*`
|
|
27
|
+
# fail-closed (no token configured → login never validates → studio inaccessible).
|
|
28
|
+
# Replaces the manual `LocalAdminShim`/Bearer. CSRF on the POSTs.
|
|
29
|
+
#
|
|
30
|
+
# Same-origin assets: versioned esbuild bundle in `assets/dist/*`, served
|
|
31
|
+
# by `/studio/assets/dist/*`. Strict `'self'` CSP (no `unsafe-inline`).
|
|
32
|
+
class App < Roda
|
|
33
|
+
include Forms # form -> command-payload parsing
|
|
34
|
+
include NavIcons # nav SVG helper
|
|
35
|
+
|
|
36
|
+
# The session cookie lives N days. 7 days = parity with the OpenClaw default.
|
|
37
|
+
SESSION_MAX_AGE = 7 * 24 * 3600
|
|
38
|
+
ASSETS_DIR = File.expand_path("assets/dist", __dir__)
|
|
39
|
+
|
|
40
|
+
CONTENT_TYPES = {
|
|
41
|
+
".js" => "text/javascript; charset=utf-8",
|
|
42
|
+
".css" => "text/css; charset=utf-8",
|
|
43
|
+
".map" => "application/json; charset=utf-8",
|
|
44
|
+
".woff2" => "font/woff2", ".svg" => "image/svg+xml"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Plugins that do NOT depend on the secret (loaded at class definition).
|
|
48
|
+
plugin :render, views: File.expand_path("views", __dir__), engine: "erb",
|
|
49
|
+
layout: "layout", escape: true
|
|
50
|
+
plugin :hash_branches
|
|
51
|
+
plugin :h
|
|
52
|
+
|
|
53
|
+
# Strict CSP: no `unsafe-inline`. Everything comes from the same-origin bundle in
|
|
54
|
+
# /studio/assets/dist. `connect-src 'self'` covers the playground's EventSource
|
|
55
|
+
# (SSE from /studio/events, same origin). `img-src data:` covers inline SVG/icons.
|
|
56
|
+
plugin :content_security_policy do |csp|
|
|
57
|
+
csp.default_src :none
|
|
58
|
+
csp.script_src :self
|
|
59
|
+
csp.style_src :self
|
|
60
|
+
csp.img_src :self, "data:"
|
|
61
|
+
csp.font_src :self
|
|
62
|
+
csp.connect_src :self
|
|
63
|
+
csp.form_action :self
|
|
64
|
+
csp.base_uri :none
|
|
65
|
+
csp.frame_ancestors :none
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Runtime dependencies injected at boot (same surface as Server::App).
|
|
70
|
+
attr_reader :insika
|
|
71
|
+
|
|
72
|
+
# Studio wiring (called by the boot: serve_real / config.ru). Loads the
|
|
73
|
+
# plugins that depend on the secret (sessions/csrf/flash) and stores the deps.
|
|
74
|
+
# An explicit `session_secret` is for the specs; in production it derives from the
|
|
75
|
+
# admin token (stable across restarts, without requiring one more env var).
|
|
76
|
+
#
|
|
77
|
+
# Besides the usual trio (command_bus/profile_source/config), the
|
|
78
|
+
# Studio now READS authoring stores (agent_file/skill/tool/memory/session)
|
|
79
|
+
# to render the pages. All optional (default nil): pages that
|
|
80
|
+
# depend on a store degrade to an empty-state if it was not injected.
|
|
81
|
+
def configure(command_bus:, profile_source:, event_stream:, config:,
|
|
82
|
+
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
83
|
+
tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
|
|
84
|
+
settings_store: nil, llm_provider_store: nil, mcp_store: nil,
|
|
85
|
+
system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
|
|
86
|
+
task_store: nil, checkpoint_store: nil, pending_action_store: nil,
|
|
87
|
+
refinement_store: nil, golden_store: nil, session_secret: nil)
|
|
88
|
+
@insika = {
|
|
89
|
+
command_bus: command_bus, profile_source: profile_source,
|
|
90
|
+
event_stream: event_stream, config: config,
|
|
91
|
+
agent_file_store: agent_file_store, skill_store: skill_store,
|
|
92
|
+
skill_catalog: skill_catalog, tool_catalog: tool_catalog,
|
|
93
|
+
# tool_store: DATA-DEFINED tool definitions. The catalog already
|
|
94
|
+
# shows the data-tools in the matrix; the store feeds the authoring page.
|
|
95
|
+
tool_store: tool_store,
|
|
96
|
+
memory_store: memory_store, session_store: session_store,
|
|
97
|
+
# runtime config (settings/LLM/MCP) + global system
|
|
98
|
+
# files + conversations index. All optional (empty-state if nil).
|
|
99
|
+
settings_store: settings_store, llm_provider_store: llm_provider_store,
|
|
100
|
+
mcp_store: mcp_store, system_file_store: system_file_store,
|
|
101
|
+
# per-session tool-call trace (debug): args + result + status per
|
|
102
|
+
# turn, rendered in the session viewer.
|
|
103
|
+
tool_trace_store: tool_trace_store,
|
|
104
|
+
# per-session context breakdown: tokens by category +
|
|
105
|
+
# budget per turn, on the same viewer. Counts only, no content.
|
|
106
|
+
context_trace_store: context_trace_store,
|
|
107
|
+
# operate: tasks + human-in-the-loop approvals. Reads the
|
|
108
|
+
# task/checkpoint/pending stores to render; controls (pause/resume/
|
|
109
|
+
# cancel/approve) dispatch on the bus — parity with server/admin.
|
|
110
|
+
task_store: task_store, checkpoint_store: checkpoint_store,
|
|
111
|
+
pending_action_store: pending_action_store,
|
|
112
|
+
# refinement runs: the ranked failure report per agent.
|
|
113
|
+
# Read-only here; the "Run" button dispatches :run_refinement on the bus.
|
|
114
|
+
refinement_store: refinement_store,
|
|
115
|
+
# eval cases: read to render; writes go through:write_golden.
|
|
116
|
+
golden_store: golden_store
|
|
117
|
+
}.freeze
|
|
118
|
+
# "restart recommended" flag — in memory, PER PROCESS. A
|
|
119
|
+
# config change that the runtime only re-reads at boot (e.g.: MCP instances are
|
|
120
|
+
# wired at startup) lights the flag; restarting the process clears it
|
|
121
|
+
# naturally (new process = `configure` runs again = flag reset).
|
|
122
|
+
# Deliberately not put in the session: the session survives the restart (the secret
|
|
123
|
+
# derives from the token) and the flag would stay stuck on.
|
|
124
|
+
@restart_needed = false
|
|
125
|
+
secret = session_secret || derive_secret(config[:admin_token])
|
|
126
|
+
plugin :sessions, key: "insika.studio", secret: secret,
|
|
127
|
+
max_seconds: SESSION_MAX_AGE, same_site: :lax
|
|
128
|
+
# CSRF token bound to the SESSION (not the method+path pair). route_csrf's
|
|
129
|
+
# per-path binding uses `request.path` = POST-MOUNT PATH_INFO ("/login",
|
|
130
|
+
# not "/studio/login"), which would confuse form-action × token under the
|
|
131
|
+
# URLMap. Session-bound is safe for the single-tenant target.
|
|
132
|
+
plugin :route_csrf, require_request_specific_tokens: false, csrf_failure: :empty_403
|
|
133
|
+
plugin :flash
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Deterministic per-deploy session secret (>=64 bytes required by Roda
|
|
138
|
+
# sessions). Derives from the admin token → stable across restarts (the session
|
|
139
|
+
# survives) without a new env var; change the token and all sessions drop.
|
|
140
|
+
def derive_secret(admin_token)
|
|
141
|
+
Digest::SHA512.hexdigest("insika-studio-session-v1:#{admin_token}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- Restart recommended — per-process state -------------------
|
|
145
|
+
def restart_needed? = @restart_needed == true
|
|
146
|
+
def mark_restart_needed! = (@restart_needed = true)
|
|
147
|
+
def clear_restart_needed! = (@restart_needed = false)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
route do |r|
|
|
151
|
+
response["x-content-type-options"] = "nosniff"
|
|
152
|
+
response["referrer-policy"] = "same-origin"
|
|
153
|
+
|
|
154
|
+
# Per-request CSP nonce. `style-src 'self'` alone blocks inline <style>, and
|
|
155
|
+
# both CodeMirror (style-mod injects a <style> for its base theme + syntax
|
|
156
|
+
# highlight) and Turbo (the progress-bar <style>) rely on one. Whitelisting a
|
|
157
|
+
# per-response nonce lets exactly those styles load WITHOUT opening the policy to
|
|
158
|
+
# 'unsafe-inline'. The editor passes the same nonce to CodeMirror via
|
|
159
|
+
# EditorView.cspNonce, and Turbo reads it from <meta name="csp-nonce"> — both
|
|
160
|
+
# sourced from the `csp_nonce` helper, so header and markup always agree.
|
|
161
|
+
response.content_security_policy&.add_style_src([:nonce, csp_nonce])
|
|
162
|
+
|
|
163
|
+
# Versioned assets: public (the UI loads the bundle BEFORE login).
|
|
164
|
+
r.on "assets", "dist" do
|
|
165
|
+
r.get String do |name|
|
|
166
|
+
serve_asset(name)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Login: the only path without a session. GET shows the form; POST validates the
|
|
171
|
+
# token in constant time and, if ok, marks the session and redirects.
|
|
172
|
+
r.is "login" do
|
|
173
|
+
r.get { view("login") }
|
|
174
|
+
r.post do
|
|
175
|
+
check_csrf!
|
|
176
|
+
if authenticate(r.params["token"])
|
|
177
|
+
session["auth"] = true
|
|
178
|
+
r.redirect("/studio/agents")
|
|
179
|
+
else
|
|
180
|
+
@error = "Invalid token, or the studio is disabled (set ADMIN_TOKEN)."
|
|
181
|
+
response.status = 401
|
|
182
|
+
view("login")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- FAIL-CLOSED: from here down requires an authenticated session ----
|
|
188
|
+
r.redirect("/studio/login") unless authenticated?
|
|
189
|
+
|
|
190
|
+
r.post "logout" do
|
|
191
|
+
check_csrf!
|
|
192
|
+
clear_session
|
|
193
|
+
r.redirect("/studio/login")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Dismisses the "restart recommended" banner without restarting (the operator
|
|
197
|
+
# acknowledges and moves on). A real restart clears the flag on its own.
|
|
198
|
+
r.post "restart-ack" do
|
|
199
|
+
check_csrf!
|
|
200
|
+
self.class.clear_restart_needed!
|
|
201
|
+
r.redirect(safe_back(r.params["back"]))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# GET /studio/events — the live transcript's SSE, on the STUDIO's own auth.
|
|
205
|
+
# `EventSource` cannot send an Authorization header, so the browser used to read
|
|
206
|
+
# the server's `/v1/events` directly — which is why that route had to stay open to
|
|
207
|
+
# the world, streaming assistant text for any session to anyone who knew the URL.
|
|
208
|
+
# Now the browser talks to the Studio (session cookie, already required above) and
|
|
209
|
+
# `/v1` is machine-only, behind its Bearer. Same process, same EventStream, same
|
|
210
|
+
# wire — only the door changed.
|
|
211
|
+
r.get "events" do
|
|
212
|
+
next_404 unless insika[:event_stream] # no stream wired: the feature is not there
|
|
213
|
+
subscription = insika[:event_stream].subscribe(
|
|
214
|
+
task_id: presence(r.params["task_id"]), session_id: presence(r.params["session_id"])
|
|
215
|
+
)
|
|
216
|
+
r.halt([200,
|
|
217
|
+
{ "content-type" => "text/event-stream", "cache-control" => "no-cache",
|
|
218
|
+
"x-accel-buffering" => "no" },
|
|
219
|
+
Insika::Server::SSEBody.new(subscription: subscription)])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `/studio` and `/studio/` → overview home.
|
|
223
|
+
r.root { r.redirect("/studio/home") }
|
|
224
|
+
|
|
225
|
+
# --- Overview: at-a-glance dashboard ---------------------
|
|
226
|
+
r.on "home" do
|
|
227
|
+
r.is { r.get { render_home } }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# --- Agents: list + detail/authoring ---------------------
|
|
231
|
+
r.on "agents" do
|
|
232
|
+
# /studio/agents — agents grid (reads the ProfileSource).
|
|
233
|
+
r.is do
|
|
234
|
+
r.get do
|
|
235
|
+
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
236
|
+
view("agents")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# POST /studio/agents — creates an agent ("everyone creates
|
|
240
|
+
# their own BIA"). Fires :create_agent; redirects to the new one's detail.
|
|
241
|
+
r.post do
|
|
242
|
+
check_csrf!
|
|
243
|
+
id = presence(r.params["id"])
|
|
244
|
+
result = with_flash("Agent '#{id}' created.") do
|
|
245
|
+
dispatch(:create_agent, {
|
|
246
|
+
id: id, model: presence(r.params["model"]),
|
|
247
|
+
provider: presence(r.params["provider"]),
|
|
248
|
+
memory: r.params["memory"] == "1"
|
|
249
|
+
})
|
|
250
|
+
end
|
|
251
|
+
r.redirect(result ? agent_path(id) : "/studio/agents")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# /studio/agents/:id — an agent's authoring page.
|
|
256
|
+
r.on String do |id|
|
|
257
|
+
id = utf8(id)
|
|
258
|
+
@agent = insika[:profile_source].fetch(id)
|
|
259
|
+
next_404 unless @agent
|
|
260
|
+
|
|
261
|
+
# GET /studio/agents/:id — config + prompts + skills + memory + history.
|
|
262
|
+
r.is do
|
|
263
|
+
r.get { render_agent_detail }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Config/model → :update_agent (patch merge).
|
|
267
|
+
r.post "config" do
|
|
268
|
+
check_csrf!
|
|
269
|
+
with_flash("Configuration saved.") do
|
|
270
|
+
dispatch(:update_agent, config_patch(r))
|
|
271
|
+
end
|
|
272
|
+
r.redirect(agent_path(id))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Store-backed prompts. Writing also ensures the file
|
|
276
|
+
# enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
|
|
277
|
+
# prompt_files is synced by the Commands themselves (write/delete
|
|
278
|
+
# register/remove the file) — the Studio just dispatches the operation.
|
|
279
|
+
r.on "prompts" do
|
|
280
|
+
r.post "delete" do
|
|
281
|
+
check_csrf!
|
|
282
|
+
with_flash("Prompt removed.") do
|
|
283
|
+
dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
|
|
284
|
+
end
|
|
285
|
+
r.redirect(agent_path(id))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
r.post "restore" do
|
|
289
|
+
check_csrf!
|
|
290
|
+
with_flash("Version restored.") do
|
|
291
|
+
dispatch(:restore_agent_file, {
|
|
292
|
+
agent_id: id, file: presence(r.params["file"]),
|
|
293
|
+
version: r.params["version"]
|
|
294
|
+
})
|
|
295
|
+
end
|
|
296
|
+
r.redirect(agent_path(id))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
r.post do
|
|
300
|
+
check_csrf!
|
|
301
|
+
with_flash("Prompt saved.") do
|
|
302
|
+
dispatch(:write_agent_file, {
|
|
303
|
+
agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
|
|
304
|
+
})
|
|
305
|
+
end
|
|
306
|
+
r.redirect(agent_path(id))
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
r.on "skills" do
|
|
311
|
+
# POST /studio/agents/:id/skills → :update_agent with the `skills`
|
|
312
|
+
# allowlist. "all" = nil; otherwise the checked subset (possibly []).
|
|
313
|
+
r.is do
|
|
314
|
+
r.post do
|
|
315
|
+
check_csrf!
|
|
316
|
+
skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
|
|
317
|
+
with_flash("Skills updated.") do
|
|
318
|
+
dispatch(:update_agent, { id: id, skills: skills })
|
|
319
|
+
end
|
|
320
|
+
r.redirect(agent_path(id))
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# /studio/agents/:id/skills/:name — this agent's OWN version of a skill.
|
|
325
|
+
# TWO path segments, which is exactly why the store takes the agent as a
|
|
326
|
+
# second argument: a composite "agent/name" key would put a `/` inside
|
|
327
|
+
# what this route serves as one segment.
|
|
328
|
+
r.on String do |name|
|
|
329
|
+
name = utf8(name)
|
|
330
|
+
r.is do
|
|
331
|
+
r.get do
|
|
332
|
+
load_skills_master(agent: id)
|
|
333
|
+
@selected = name
|
|
334
|
+
@scope_agent = id
|
|
335
|
+
@skill_content = skill_source(name, agent: id)
|
|
336
|
+
view("skills")
|
|
337
|
+
end
|
|
338
|
+
# Saving the specialization: same command, `agent:` set.
|
|
339
|
+
r.post do
|
|
340
|
+
check_csrf!
|
|
341
|
+
with_flash("Specialization saved.") do
|
|
342
|
+
dispatch(:write_skill, { name: name, agent: id, content: r.params["content"].to_s })
|
|
343
|
+
end
|
|
344
|
+
r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Un-specialize: the shared skill stays, and the agent falls back to it.
|
|
349
|
+
r.post "delete" do
|
|
350
|
+
check_csrf!
|
|
351
|
+
with_flash("Specialization removed — the agent falls back to the shared skill.") do
|
|
352
|
+
dispatch(:delete_skill, { name: name, agent: id })
|
|
353
|
+
end
|
|
354
|
+
r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Agent memory. Scoped by tenant = agent id — the
|
|
360
|
+
# SAME tenant the playground uses when chatting, so what is edited
|
|
361
|
+
# here is what the BIA reads on the turn. Each agent, its own memory.
|
|
362
|
+
r.on "memory" do
|
|
363
|
+
r.post "fact" do
|
|
364
|
+
check_csrf!
|
|
365
|
+
with_flash("Fact saved.") do
|
|
366
|
+
dispatch(:memory_put_fact, {
|
|
367
|
+
tenant: id, key: presence(r.params["key"]), value: r.params["value"].to_s
|
|
368
|
+
})
|
|
369
|
+
end
|
|
370
|
+
r.redirect(agent_path(id, "memory"))
|
|
371
|
+
end
|
|
372
|
+
r.post "forget" do
|
|
373
|
+
check_csrf!
|
|
374
|
+
with_flash("Fact forgotten.") do
|
|
375
|
+
dispatch(:memory_forget_fact, { tenant: id, key: presence(r.params["key"]) })
|
|
376
|
+
end
|
|
377
|
+
r.redirect(agent_path(id, "memory"))
|
|
378
|
+
end
|
|
379
|
+
r.post "note" do
|
|
380
|
+
check_csrf!
|
|
381
|
+
with_flash("Note added.") do
|
|
382
|
+
dispatch(:memory_add_note, { tenant: id, text: r.params["text"].to_s })
|
|
383
|
+
end
|
|
384
|
+
r.redirect(agent_path(id, "memory"))
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# --- Skills: catalog + agents matrix + editor ----------------
|
|
391
|
+
r.on "skills" do
|
|
392
|
+
r.is do
|
|
393
|
+
r.get { render_skills_index }
|
|
394
|
+
# POST /studio/skills → writes the skill (full SKILL.md) and reloads
|
|
395
|
+
# the catalog (hot). Covers "new skill" and "save edit".
|
|
396
|
+
r.post do
|
|
397
|
+
check_csrf!
|
|
398
|
+
name = presence(r.params["name"])
|
|
399
|
+
with_flash("Skill saved.") do
|
|
400
|
+
dispatch(:write_skill, { name: name, content: r.params["content"].to_s })
|
|
401
|
+
end
|
|
402
|
+
r.redirect(name ? "/studio/skills/#{Rack::Utils.escape(name)}" : "/studio/skills")
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# New skill editor (before the generic String matcher).
|
|
407
|
+
r.get "new" do
|
|
408
|
+
load_skills_master
|
|
409
|
+
@selected = ""
|
|
410
|
+
@skill_content = new_skill_template
|
|
411
|
+
view("skills")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
r.on String do |name|
|
|
415
|
+
name = utf8(name)
|
|
416
|
+
# GET /studio/skills/:name — drill with this skill open in the detail.
|
|
417
|
+
r.is do
|
|
418
|
+
r.get do
|
|
419
|
+
load_skills_master
|
|
420
|
+
@selected = name
|
|
421
|
+
@skill_content = skill_source(name)
|
|
422
|
+
view("skills")
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
# Enables/disables the skill on N agents at once, and marks it always-on for
|
|
426
|
+
# the ones that checked `eager`. Both are per-agent decisions about the same
|
|
427
|
+
# skill, so they are the same form and the same dispatch.
|
|
428
|
+
r.post "agents" do
|
|
429
|
+
check_csrf!
|
|
430
|
+
agent_ids = Array(r.params["agent_ids"]).map(&:to_s)
|
|
431
|
+
eager_ids = Array(r.params["eager_ids"]).map(&:to_s)
|
|
432
|
+
result = with_flash("Skill's agents updated.") do
|
|
433
|
+
dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids, eager_ids: eager_ids })
|
|
434
|
+
end
|
|
435
|
+
note_skipped(result)
|
|
436
|
+
r.redirect("/studio/skills")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Seeds a per-agent override from the SHARED body and opens it for editing.
|
|
440
|
+
# Seeded and not blank: specializing means "this, but for me" — starting from
|
|
441
|
+
# an empty editor is how the two versions drift apart on day one.
|
|
442
|
+
r.post "specialize" do
|
|
443
|
+
check_csrf!
|
|
444
|
+
agent_id = presence(r.params["agent_id"])
|
|
445
|
+
with_flash("Specialized for #{agent_id}.") do
|
|
446
|
+
dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
|
|
447
|
+
end
|
|
448
|
+
r.redirect("/studio/agents/#{Rack::Utils.escape(agent_id.to_s)}/skills/#{Rack::Utils.escape(name)}")
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
|
|
454
|
+
r.on "tools" do
|
|
455
|
+
# Data-defined tool authoring. Under /tools/def/* — BEFORE the
|
|
456
|
+
# generic `r.post String` matcher (which is the allow/deny matrix per agent :id).
|
|
457
|
+
r.on "def" do
|
|
458
|
+
# /studio/tools/def/new — empty editor.
|
|
459
|
+
r.get "new" do
|
|
460
|
+
render_tool_edit(name: "", tool: nil)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# POST /studio/tools/def — creates (create_only: refuses to overwrite).
|
|
464
|
+
r.is do
|
|
465
|
+
r.post do
|
|
466
|
+
check_csrf!
|
|
467
|
+
name = presence(r.params["name"])
|
|
468
|
+
result = with_flash("Tool '#{name}' created.") do
|
|
469
|
+
dispatch(:write_data_tool, tool_patch(r).merge(create_only: true))
|
|
470
|
+
end
|
|
471
|
+
r.redirect(result ? tool_def_path(name) : "/studio/tools")
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
r.on String do |name|
|
|
476
|
+
name = utf8(name)
|
|
477
|
+
# GET /studio/tools/def/:name — loaded editor (secret masked).
|
|
478
|
+
r.is do
|
|
479
|
+
r.get do
|
|
480
|
+
tool = insika[:tool_store]&.get(name)
|
|
481
|
+
next_404 unless tool
|
|
482
|
+
render_tool_edit(name: name, tool: tool)
|
|
483
|
+
end
|
|
484
|
+
# POST /studio/tools/def/:name — updates (upsert).
|
|
485
|
+
r.post do
|
|
486
|
+
check_csrf!
|
|
487
|
+
# The stored definition rides along so the fields this form does not
|
|
488
|
+
# render (group/tags/halt_when) survive the save — see UNEDITED_TOOL_FIELDS.
|
|
489
|
+
stored = insika[:tool_store]&.get(name)
|
|
490
|
+
with_flash("Tool '#{name}' saved.") { dispatch(:write_data_tool, tool_patch(r, stored)) }
|
|
491
|
+
r.redirect(tool_def_path(name))
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
r.post "delete" do
|
|
495
|
+
check_csrf!
|
|
496
|
+
with_flash("Tool '#{name}' removed.") { dispatch(:delete_data_tool, { name: name }) }
|
|
497
|
+
r.redirect("/studio/tools")
|
|
498
|
+
end
|
|
499
|
+
r.post "restore" do
|
|
500
|
+
check_csrf!
|
|
501
|
+
with_flash("Version restored.") do
|
|
502
|
+
dispatch(:restore_data_tool, { name: name, index: r.params["index"] })
|
|
503
|
+
end
|
|
504
|
+
r.redirect(tool_def_path(name))
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
r.is { r.get { render_tools_matrix } }
|
|
510
|
+
|
|
511
|
+
# POST /studio/tools/:id — writes an agent's tools allowlist.
|
|
512
|
+
# "all" = nil; otherwise the checked subset. `deny` is preserved.
|
|
513
|
+
r.post String do |id|
|
|
514
|
+
check_csrf!
|
|
515
|
+
id = utf8(id)
|
|
516
|
+
profile = insika[:profile_source].fetch(id)
|
|
517
|
+
next_404 unless profile
|
|
518
|
+
allow = r.params["all_tools"] == "1" ? nil : Array(r.params["tools"]).map(&:to_s)
|
|
519
|
+
with_flash("Agent '#{id}' tools updated.") do
|
|
520
|
+
dispatch(:set_agent_tools, { id: id, allow: allow, deny: Array(profile.tools_deny) })
|
|
521
|
+
end
|
|
522
|
+
r.redirect("/studio/tools?a=#{Rack::Utils.escape(id)}")
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# --- General settings + LLM providers ------------------------
|
|
527
|
+
r.on "settings" do
|
|
528
|
+
r.is do
|
|
529
|
+
r.get { render_settings }
|
|
530
|
+
# General settings (streaming/timeouts) → :update_settings.
|
|
531
|
+
r.post do
|
|
532
|
+
check_csrf!
|
|
533
|
+
with_flash("Settings saved.") do
|
|
534
|
+
dispatch(:update_settings, { patch: settings_patch(r) })
|
|
535
|
+
end
|
|
536
|
+
r.redirect("/studio/settings")
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Platform model defaults (sub-resource, v2): its own form/route so a
|
|
541
|
+
# general-settings save never clobbers the model layer.
|
|
542
|
+
r.post "models" do
|
|
543
|
+
check_csrf!
|
|
544
|
+
with_flash("Model defaults saved.") do
|
|
545
|
+
dispatch(:update_settings, { patch: model_defaults_patch(r) })
|
|
546
|
+
end
|
|
547
|
+
r.redirect("/studio/settings?s=models")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Per-model reasoning defaults (the per-model layer): its own form so a
|
|
551
|
+
# model-defaults save never clobbers the per-model map, and vice-versa.
|
|
552
|
+
r.post "model-params" do
|
|
553
|
+
check_csrf!
|
|
554
|
+
with_flash("Per-model params saved.") do
|
|
555
|
+
dispatch(:update_settings, { patch: model_params_patch(r) })
|
|
556
|
+
end
|
|
557
|
+
r.redirect("/studio/settings?s=models")
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Edge limits: the platform rate-limit/cost layer.
|
|
561
|
+
# Its own form for the same reason as models — saves never cross-clobber.
|
|
562
|
+
r.post "edge" do
|
|
563
|
+
check_csrf!
|
|
564
|
+
with_flash("Edge limits saved.") do
|
|
565
|
+
dispatch(:update_settings, { patch: edge_patch(r) })
|
|
566
|
+
end
|
|
567
|
+
r.redirect("/studio/settings?s=edge")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Evals: the judge PANEL and how it agrees. Its own
|
|
571
|
+
# form, like models and edge — a save here must not clobber those.
|
|
572
|
+
r.post "evals" do
|
|
573
|
+
check_csrf!
|
|
574
|
+
with_flash("Evals settings saved.") do
|
|
575
|
+
dispatch(:update_settings, { patch: evals_patch(r) })
|
|
576
|
+
end
|
|
577
|
+
r.redirect("/studio/settings?s=evals")
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# LLM providers (sub-resource): CRUD with masked api_key (sentinel).
|
|
581
|
+
r.on "providers" do
|
|
582
|
+
r.post "delete" do
|
|
583
|
+
check_csrf!
|
|
584
|
+
with_flash("Provider removed.") do
|
|
585
|
+
dispatch(:delete_llm_provider, { api: presence(r.params["api"]) })
|
|
586
|
+
end
|
|
587
|
+
r.redirect("/studio/settings?s=llm")
|
|
588
|
+
end
|
|
589
|
+
r.post do
|
|
590
|
+
check_csrf!
|
|
591
|
+
with_flash("Provider saved.") do
|
|
592
|
+
dispatch(:upsert_llm_provider, provider_patch(r))
|
|
593
|
+
end
|
|
594
|
+
r.redirect("/studio/settings?s=llm")
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# --- MCP: instances with masked credentials ------------------
|
|
600
|
+
r.on "mcp" do
|
|
601
|
+
r.is do
|
|
602
|
+
r.get { render_mcp }
|
|
603
|
+
r.post do
|
|
604
|
+
check_csrf!
|
|
605
|
+
with_flash("MCP instance saved.") do
|
|
606
|
+
dispatch(:upsert_mcp, mcp_patch(r))
|
|
607
|
+
# MCP servers are wired at runtime boot; the new instance
|
|
608
|
+
# only takes effect after a restart. Lights the "restart" banner.
|
|
609
|
+
self.class.mark_restart_needed!
|
|
610
|
+
end
|
|
611
|
+
r.redirect("/studio/mcp")
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
r.post "delete" do
|
|
615
|
+
check_csrf!
|
|
616
|
+
with_flash("MCP instance removed.") do
|
|
617
|
+
dispatch(:delete_mcp, { name: presence(r.params["name"]) })
|
|
618
|
+
self.class.mark_restart_needed!
|
|
619
|
+
end
|
|
620
|
+
r.redirect("/studio/mcp")
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# --- Global system files -------------------------------------
|
|
625
|
+
# Apply to ALL agents (the Prompt provider injects them before the
|
|
626
|
+
# individual identity). Code-editor + versions, like the prompts.
|
|
627
|
+
r.on "system-files" do
|
|
628
|
+
r.is do
|
|
629
|
+
r.get { render_system_files }
|
|
630
|
+
r.post do
|
|
631
|
+
check_csrf!
|
|
632
|
+
with_flash("System file saved.") do
|
|
633
|
+
dispatch(:write_system_file, {
|
|
634
|
+
file: presence(r.params["file"]), content: r.params["content"].to_s
|
|
635
|
+
})
|
|
636
|
+
end
|
|
637
|
+
r.redirect("/studio/system-files")
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
r.post "delete" do
|
|
641
|
+
check_csrf!
|
|
642
|
+
with_flash("File removed.") do
|
|
643
|
+
dispatch(:delete_system_file, { file: presence(r.params["file"]) })
|
|
644
|
+
end
|
|
645
|
+
r.redirect("/studio/system-files")
|
|
646
|
+
end
|
|
647
|
+
r.post "restore" do
|
|
648
|
+
check_csrf!
|
|
649
|
+
with_flash("Version restored.") do
|
|
650
|
+
dispatch(:restore_system_file, {
|
|
651
|
+
file: presence(r.params["file"]), version: r.params["version"]
|
|
652
|
+
})
|
|
653
|
+
end
|
|
654
|
+
r.redirect("/studio/system-files")
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# --- Chats: conversations index ------------------------------
|
|
659
|
+
# Read-only: lists sessions and links to the existing viewer (/sessions/:id).
|
|
660
|
+
r.on "chats" do
|
|
661
|
+
r.is { r.get { render_chats } }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# --- History: read-only viewer of a session ------------------
|
|
665
|
+
r.on "sessions" do
|
|
666
|
+
r.on String do |sid|
|
|
667
|
+
sid = utf8(sid)
|
|
668
|
+
r.get do
|
|
669
|
+
@session = insika[:session_store]&.find(sid)
|
|
670
|
+
next_404 unless @session
|
|
671
|
+
|
|
672
|
+
# Session's tool-call trace (debug): grouped by turn in the view.
|
|
673
|
+
@tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
|
|
674
|
+
.group_by { |t| t["turn"] }
|
|
675
|
+
# Context breakdown per turn: chronological entries.
|
|
676
|
+
@context_traces = insika[:context_trace_store]&.for_session(sid) || []
|
|
677
|
+
view("session")
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Tasks: list + detail + operator controls -------
|
|
683
|
+
# Parity with server/admin: READS the task/checkpoint/pending stores to
|
|
684
|
+
# render; pause/resume/cancel dispatch Commands on the bus (never a direct
|
|
685
|
+
# store write). Every control audits the ATTEMPT to the EventStream first.
|
|
686
|
+
r.on "tasks" do
|
|
687
|
+
r.is { r.get { render_tasks } }
|
|
688
|
+
|
|
689
|
+
r.on String do |id|
|
|
690
|
+
id = utf8(id)
|
|
691
|
+
r.get do
|
|
692
|
+
@task = insika[:task_store]&.find(id)
|
|
693
|
+
next_404 unless @task
|
|
694
|
+
|
|
695
|
+
render_task_detail(id)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
r.post "pause" do
|
|
699
|
+
check_csrf!
|
|
700
|
+
control_action(:pause_task, { task_id: id }, ok: "Task paused.")
|
|
701
|
+
r.redirect(task_path(id))
|
|
702
|
+
end
|
|
703
|
+
r.post "resume" do
|
|
704
|
+
check_csrf!
|
|
705
|
+
control_action(:resume_task, { task_id: id }, ok: "Task resumed.")
|
|
706
|
+
r.redirect(task_path(id))
|
|
707
|
+
end
|
|
708
|
+
r.post "cancel" do
|
|
709
|
+
check_csrf!
|
|
710
|
+
control_action(:cancel_task, { task_id: id }, ok: "Task cancelled.")
|
|
711
|
+
r.redirect(task_path(id))
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Approvals: human-in-the-loop inbox -------------
|
|
717
|
+
# Lists every :pending action across tasks; resolving one dispatches
|
|
718
|
+
# :approve_action, which resolves the store AND wakes the suspended turn.
|
|
719
|
+
r.on "approvals" do
|
|
720
|
+
r.is { r.get { render_approvals } }
|
|
721
|
+
|
|
722
|
+
r.on String do |pid|
|
|
723
|
+
pid = utf8(pid)
|
|
724
|
+
r.post do
|
|
725
|
+
check_csrf!
|
|
726
|
+
decision = r.params["decision"] == "rejected" ? "rejected" : "approved"
|
|
727
|
+
control_action(:approve_action,
|
|
728
|
+
{ pending_id: pid, decision: decision, operator: operator_label },
|
|
729
|
+
ok: "Approval #{decision}.")
|
|
730
|
+
r.redirect(safe_back(r.params["back"], default: "/studio/approvals"))
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Evals: the cases that grade an agent -------
|
|
736
|
+
# A case is DATA in the same YAML shape the corpus files use, so what an operator
|
|
737
|
+
# edits here is what a pull request would review. The one loader validates it, on
|
|
738
|
+
# the way in — a malformed case is a red flash, never a silently skipped test.
|
|
739
|
+
r.on "evals" do
|
|
740
|
+
r.is do
|
|
741
|
+
r.get { render_evals }
|
|
742
|
+
r.post do
|
|
743
|
+
check_csrf!
|
|
744
|
+
with_flash("Case saved.") { dispatch(:write_golden, golden_patch(r)) }
|
|
745
|
+
r.redirect("/studio/evals?id=#{Rack::Utils.escape(presence(r.params['id']).to_s)}")
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
r.on String do |id|
|
|
750
|
+
id = utf8(id)
|
|
751
|
+
r.post "delete" do
|
|
752
|
+
check_csrf!
|
|
753
|
+
with_flash("Case removed.") { dispatch(:delete_golden, { id: id }) }
|
|
754
|
+
r.redirect("/studio/evals")
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# --- Refinement: what broke in real traffic, and what to do about it --
|
|
760
|
+
# `POST /refinement` runs the report. `POST
|
|
761
|
+
# refinement/propose` is: the configured model writes a candidate from
|
|
762
|
+
# the findings and the gate scores it by replaying the golden set. `POST
|
|
763
|
+
# /refinement/resolve` is a human approving or rejecting what the gate passed.
|
|
764
|
+
# All three go through the bus like every other Studio write — this page reads
|
|
765
|
+
# stores and dispatches Commands, nothing else.
|
|
766
|
+
#
|
|
767
|
+
# There is still no form to hand-AUTHOR a candidate: a JSON textarea would be a
|
|
768
|
+
# worse way to say what the API already says, and the button below is what an
|
|
769
|
+
# operator actually wants standing there.
|
|
770
|
+
r.on "refinement" do
|
|
771
|
+
r.is do
|
|
772
|
+
r.get { render_refinement }
|
|
773
|
+
r.post do
|
|
774
|
+
check_csrf!
|
|
775
|
+
agent = presence(r.params["agent"])
|
|
776
|
+
payload = { agent: agent, full: r.params["full"] == "1" }
|
|
777
|
+
control_action(:run_refinement, payload, ok: "Refinement run finished.")
|
|
778
|
+
r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Propose + gate in one press, because they are one decision for the operator
|
|
783
|
+
# ("try to fix this") and splitting them would park a run holding an unscored
|
|
784
|
+
# candidate — a state nobody can act on. Slow on purpose: the gate replays the
|
|
785
|
+
# golden set, so this returns when the answer is real.
|
|
786
|
+
r.post "propose" do
|
|
787
|
+
check_csrf!
|
|
788
|
+
agent = presence(r.params["agent"])
|
|
789
|
+
payload = { run_id: presence(r.params["run_id"]), propose: true }
|
|
790
|
+
control_action(:gate_refinement, payload, ok: "Proposal gated — see the result below.")
|
|
791
|
+
r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
r.post "resolve" do
|
|
795
|
+
check_csrf!
|
|
796
|
+
agent = presence(r.params["agent"])
|
|
797
|
+
decision = presence(r.params["decision"])
|
|
798
|
+
payload = { run_id: presence(r.params["run_id"]), decision: decision,
|
|
799
|
+
operator: "studio", note: presence(r.params["note"]) }.compact
|
|
800
|
+
ok = decision == "approved" ? "Applied — the agent's files were updated." : "Proposal rejected."
|
|
801
|
+
control_action(:resolve_refinement, payload, ok: ok)
|
|
802
|
+
r.redirect("/studio/refinement?agent=#{Rack::Utils.escape(agent.to_s)}")
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Playground: sends `send_message` (the SAME Command as the API) and streams the
|
|
807
|
+
# response live through the `live-transcript` island (SSE from /studio/events).
|
|
808
|
+
r.on "playground" do
|
|
809
|
+
r.get do
|
|
810
|
+
@agent = presence(r.params["agent"]) || default_agent
|
|
811
|
+
@session_id = presence(r.params["session_id"])
|
|
812
|
+
@agents = insika[:profile_source].ids.sort
|
|
813
|
+
# Server-side echo + continuity: render the session's persisted
|
|
814
|
+
# transcript as bubbles. The user's message is only persisted at the END
|
|
815
|
+
# of the turn, so the just-sent message rides a one-shot flash bubble
|
|
816
|
+
# (@sent_message) until it lands in history — an optimistic JS echo can't
|
|
817
|
+
# work here (POST→redirect wipes the DOM).
|
|
818
|
+
@history = @session_id ? Array(insika[:session_store]&.find(@session_id)&.messages) : []
|
|
819
|
+
@sent_message = flash["sent_message"]
|
|
820
|
+
view("playground")
|
|
821
|
+
end
|
|
822
|
+
r.post do
|
|
823
|
+
check_csrf!
|
|
824
|
+
agent = presence(r.params["agent"]) || default_agent
|
|
825
|
+
typed_session = presence(r.params["session_id"])
|
|
826
|
+
message = r.params["message"].to_s
|
|
827
|
+
# Blank session = new conversation: created via Command (create_session
|
|
828
|
+
# generates the id — the Studio doesn't write to the store directly). A typed id
|
|
829
|
+
# continues an existing conversation (send_message requires it to exist).
|
|
830
|
+
# The per-chat model pin is set at creation and rides the whole
|
|
831
|
+
# conversation, so it only applies to a NEW session — an existing one keeps
|
|
832
|
+
# whatever it was pinned to.
|
|
833
|
+
session_id = typed_session ||
|
|
834
|
+
create_session(model: presence(r.params["model"]),
|
|
835
|
+
provider: presence(r.params["provider"]),
|
|
836
|
+
thinking: presence(r.params["thinking"]))
|
|
837
|
+
dispatch_send_message(agent: agent, session_id: session_id, message: message)
|
|
838
|
+
# Optimistic echo of the just-sent message: survives the redirect
|
|
839
|
+
# as a one-shot flash, rendered as a user bubble on the next GET.
|
|
840
|
+
flash["sent_message"] = message unless message.empty?
|
|
841
|
+
r.redirect(playground_path(agent, session_id))
|
|
842
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
843
|
+
flash["error"] = e.message
|
|
844
|
+
r.redirect(playground_path(agent, typed_session))
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Unknown authenticated route → friendly 404 (not Roda's empty body).
|
|
849
|
+
response.status = 404
|
|
850
|
+
view("not_found")
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
private
|
|
854
|
+
|
|
855
|
+
# --- Helpers (instance scope; available inside the views) ----------------
|
|
856
|
+
|
|
857
|
+
def insika = self.class.insika
|
|
858
|
+
|
|
859
|
+
# Sidebar navigation, grouped by operator intent (build / runtime /
|
|
860
|
+
# operate). Each item: [label, href, icon-key]. The view marks the active item
|
|
861
|
+
# by comparing the path and renders the icon via `nav_icon`.
|
|
862
|
+
def nav_sections
|
|
863
|
+
[
|
|
864
|
+
["", [
|
|
865
|
+
["Home", "/studio/home", :home]
|
|
866
|
+
]],
|
|
867
|
+
["build", [
|
|
868
|
+
["Agents", "/studio/agents", :agents],
|
|
869
|
+
["Skills", "/studio/skills", :skills],
|
|
870
|
+
["Tools", "/studio/tools", :tools],
|
|
871
|
+
["System files", "/studio/system-files", :system]
|
|
872
|
+
]],
|
|
873
|
+
["runtime", [
|
|
874
|
+
["MCP", "/studio/mcp", :mcp],
|
|
875
|
+
["Settings", "/studio/settings", :settings]
|
|
876
|
+
]],
|
|
877
|
+
["operate", [
|
|
878
|
+
["Chats", "/studio/chats", :chats],
|
|
879
|
+
["Playground", "/studio/playground", :playground],
|
|
880
|
+
["Tasks", "/studio/tasks", :tasks],
|
|
881
|
+
["Approvals", "/studio/approvals", :approvals],
|
|
882
|
+
["Refinement", "/studio/refinement", :refinement],
|
|
883
|
+
["Evals", "/studio/evals", :evals]
|
|
884
|
+
]]
|
|
885
|
+
]
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# NAV_ICONS + nav_icon moved to Studio::NavIcons.
|
|
889
|
+
|
|
890
|
+
def authenticated? = session["auth"] == true
|
|
891
|
+
|
|
892
|
+
# Is the given nav href the current page? Robust to whether request.path
|
|
893
|
+
# carries the "/studio" mount prefix (it does under serve_real/config.ru, it
|
|
894
|
+
# doesn't in specs) — strip it from both sides, then exact- or prefix-match
|
|
895
|
+
# so detail routes (/agents/:id) still light up their section.
|
|
896
|
+
def nav_active?(href)
|
|
897
|
+
target = href.sub(%r{\A/studio}, "")
|
|
898
|
+
path = request.path.sub(%r{\A/studio}, "")
|
|
899
|
+
path == target || path.start_with?("#{target}/")
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# CSP nonce for inline styles (CodeMirror's injected theme). Stable PER SESSION,
|
|
903
|
+
# not per request: the browser enforces the CSP from the initial document load
|
|
904
|
+
# for the whole SPA session, but Turbo Drive fetches later pages carrying their
|
|
905
|
+
# OWN nonce in the <meta>. A per-request nonce would therefore never match what
|
|
906
|
+
# the browser enforces after a Turbo navigation, so CodeMirror's <style> gets
|
|
907
|
+
# blocked and the editor renders unstyled/broken until a full reload. A
|
|
908
|
+
# session-lifetime nonce keeps the meta constant across Turbo visits (matches the
|
|
909
|
+
# enforced value) while still being unguessable and per-user — the standard
|
|
910
|
+
# Turbo+CSP reconciliation. Scripts stay 'self' (no nonce), so this only governs
|
|
911
|
+
# styles. Memoized on the request instance so header and <meta> agree.
|
|
912
|
+
def csp_nonce = (@csp_nonce ||= (session["csp_nonce"] ||= SecureRandom.base64(16)))
|
|
913
|
+
|
|
914
|
+
# --- Polish: theme, health chip, restart banner ----------------
|
|
915
|
+
|
|
916
|
+
def restart_needed? = self.class.restart_needed?
|
|
917
|
+
|
|
918
|
+
# Environment label for the sidebar identity chip — reflects the REAL runtime
|
|
919
|
+
# env (INSIKA_ENV → RACK_ENV → "local") so an operator always knows which box
|
|
920
|
+
# they are looking at. No new state; just reads the process env.
|
|
921
|
+
def env_label = (presence(Insika::EnvSchema.read("INSIKA_ENV")) || presence(ENV["RACK_ENV"]) || "local").to_s
|
|
922
|
+
|
|
923
|
+
# Theme preference read from the cookie (applied server-side on <html> → no
|
|
924
|
+
# flash). Strict allowlist: an unexpected value falls back to "auto".
|
|
925
|
+
THEMES = %w[auto light dark].freeze
|
|
926
|
+
def theme_pref
|
|
927
|
+
value = request.cookies["insika.theme"].to_s
|
|
928
|
+
THEMES.include?(value) ? value : "auto"
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Structured counts for the sidebar health card: [label, value]. Only what
|
|
932
|
+
# the Studio already reads — no new ping. Persistence (durable/ephemeral)
|
|
933
|
+
# comes from config, if boot supplied it (serve_real does; specs don't need).
|
|
934
|
+
def health_parts
|
|
935
|
+
parts = [["agents", insika[:profile_source].all.size]]
|
|
936
|
+
parts << ["LLM providers", insika[:llm_provider_store].all.size] if insika[:llm_provider_store]
|
|
937
|
+
parts << ["MCP servers", insika[:mcp_store].all.size] if insika[:mcp_store]
|
|
938
|
+
persistence = insika[:config][:persistence]
|
|
939
|
+
parts << ["persistence", persistence.to_s] if persistence && !persistence.to_s.empty?
|
|
940
|
+
parts
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Only redirects to a LOCAL path (avoids open-redirect via `back`):
|
|
944
|
+
# starts with "/", but not "//" (protocol-relative) nor contains a scheme.
|
|
945
|
+
def safe_back(path, default: "/studio/agents")
|
|
946
|
+
p = presence(path)
|
|
947
|
+
return default unless p&.start_with?("/")
|
|
948
|
+
return default if p.start_with?("//") || p.include?("://") || p.include?("\\")
|
|
949
|
+
|
|
950
|
+
p
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Fail-closed BY CONSTRUCTION (AdminAuth parity): with no token configured, the
|
|
954
|
+
# compare never passes → studio inaccessible. Constant-time comparison.
|
|
955
|
+
def authenticate(provided)
|
|
956
|
+
configured = insika[:config][:admin_token].to_s
|
|
957
|
+
provided = provided.to_s
|
|
958
|
+
return false if configured.empty? || provided.empty?
|
|
959
|
+
|
|
960
|
+
Rack::Utils.secure_compare(configured, provided)
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
def default_agent
|
|
964
|
+
ids = insika[:profile_source].ids
|
|
965
|
+
ids.include?("bia") ? "bia" : (ids.first || "bia")
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# --- Transcript display helpers (playground + session viewer) ------------
|
|
969
|
+
|
|
970
|
+
# A stored message's content for display: strings as-is; structured payloads
|
|
971
|
+
# as pretty JSON. NEVER Ruby #inspect (session.erb:45 leaked hashrockets to the
|
|
972
|
+
# operator)..
|
|
973
|
+
def message_content(content)
|
|
974
|
+
return content.to_s if content.is_a?(String)
|
|
975
|
+
|
|
976
|
+
JSON.pretty_generate(content)
|
|
977
|
+
rescue StandardError
|
|
978
|
+
content.to_s
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# An activation label from the context trace, as the operator reads it:
|
|
982
|
+
# "gift-concierge · trigger:presente". The REASON is the point of the card — a
|
|
983
|
+
# bare list of names answers "was something injected", never "which one did I
|
|
984
|
+
# trigger". A label with no reason (a plugin-supplied body) shows just the name.
|
|
985
|
+
def skill_label(label)
|
|
986
|
+
return label.to_s unless label.is_a?(Hash)
|
|
987
|
+
|
|
988
|
+
name = label["name"].to_s
|
|
989
|
+
reason = label["reason"].to_s
|
|
990
|
+
reason.empty? ? name : "#{name} · #{reason}"
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# One word for the whole turn, for the collapsed summary: the shared reason when
|
|
994
|
+
# every body arrived the same way, "mixed" when they did not. A turn CAN mix (the
|
|
995
|
+
# agent's eager set plus what this message triggered), which is exactly why the
|
|
996
|
+
# single per-turn `mode` this replaced was a lie waiting to happen.
|
|
997
|
+
def skill_mode(labels)
|
|
998
|
+
kinds = Array(labels).map { |l| (l.is_a?(Hash) ? l["reason"].to_s : "").split(":").first }.uniq
|
|
999
|
+
return "context" if kinds.empty? || kinds.any?(&:nil?) || kinds.include?("")
|
|
1000
|
+
|
|
1001
|
+
kinds.length == 1 ? kinds.first : "mixed"
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# ISO8601 → "HH:MM" for a transcript timestamp; "" when unparseable/absent.
|
|
1005
|
+
def short_time(iso)
|
|
1006
|
+
Time.parse(iso.to_s).strftime("%H:%M")
|
|
1007
|
+
rescue StandardError
|
|
1008
|
+
""
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# Friendly 404 from any point in the routing (missing agent/session).
|
|
1012
|
+
# `throw :halt` with the response already assembled (Roda pattern).
|
|
1013
|
+
def next_404
|
|
1014
|
+
response.status = 404
|
|
1015
|
+
response.write(view("not_found"))
|
|
1016
|
+
request.halt
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Roda captures path segments with ASCII-8BIT (binary) encoding. The Store
|
|
1020
|
+
# keys were written in UTF-8; a `get` with a binary string does NOT match in the
|
|
1021
|
+
# SQLite backend (binds as BLOB) and would even write a duplicate key if it
|
|
1022
|
+
# entered a write payload. Normalizes at the ONLY point where the binary
|
|
1023
|
+
# is born — the Roda edge — so the core (framework-agnostic) only sees
|
|
1024
|
+
# UTF-8 strings. The bytes come from the already-decoded URL (UTF-8), so
|
|
1025
|
+
# force_encoding is correct, not a reinterpretation.
|
|
1026
|
+
def utf8(str) = Insika::Coercion.utf8(str)
|
|
1027
|
+
|
|
1028
|
+
# Dispatches a Command through the bus (same surface as the API) and returns the
|
|
1029
|
+
# result. `tenant` is only used by memory.
|
|
1030
|
+
def dispatch(type, payload, tenant: nil)
|
|
1031
|
+
insika[:command_bus].dispatch(
|
|
1032
|
+
Insika::Command.build(type, payload, transport: :studio, tenant: tenant)
|
|
1033
|
+
)
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Wraps a write dispatch with a success/error flash and returns the
|
|
1037
|
+
# result (or nil on error). Domain errors (Validation/NotFound) become a
|
|
1038
|
+
# red flash — never a 500 in the user's face.
|
|
1039
|
+
def with_flash(success)
|
|
1040
|
+
result = yield
|
|
1041
|
+
flash["notice"] = success
|
|
1042
|
+
result
|
|
1043
|
+
rescue Insika::ValidationError, Insika::NotFoundError => e
|
|
1044
|
+
flash["error"] = e.message
|
|
1045
|
+
nil
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
# --- Agent detail read ---------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
def render_agent_detail
|
|
1051
|
+
id = @agent.id
|
|
1052
|
+
store = insika[:agent_file_store]
|
|
1053
|
+
@prompt_files = Array(@agent.prompt_files).map do |name|
|
|
1054
|
+
{
|
|
1055
|
+
name: name.to_s,
|
|
1056
|
+
content: store&.read(id, name).to_s,
|
|
1057
|
+
versions: store ? store.versions(id, name) : []
|
|
1058
|
+
}
|
|
1059
|
+
end
|
|
1060
|
+
@all_skills = insika[:skill_catalog]&.all || []
|
|
1061
|
+
@agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
|
|
1062
|
+
# v2 config surfaces: generation params + model fence. AgentProfile.build
|
|
1063
|
+
# string-keys these hashes, so the form helpers read plain string keys.
|
|
1064
|
+
@params = @agent.params
|
|
1065
|
+
@model_policy_allow = model_policy_allow(@agent)
|
|
1066
|
+
# guardrails config; nil when the agent never configured any.
|
|
1067
|
+
@guardrails = @agent.guardrails || {}
|
|
1068
|
+
mem = insika[:memory_store]
|
|
1069
|
+
@facts = mem ? mem.facts(tenant: id) : []
|
|
1070
|
+
@notes = mem ? mem.notes(tenant: id, limit: 20) : []
|
|
1071
|
+
@recent_sessions = recent_sessions
|
|
1072
|
+
view("agent_detail")
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# A generation param off the profile (string-keyed by AgentProfile.build). Used
|
|
1076
|
+
# to pre-fill the config form (empty string when absent).
|
|
1077
|
+
def agent_param(params, key)
|
|
1078
|
+
return "" unless params.is_a?(Hash)
|
|
1079
|
+
|
|
1080
|
+
params[key.to_s].nil? ? "" : params[key.to_s]
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
# Renders the reasoning <select> shared by the agent config, settings and
|
|
1084
|
+
# playground (4-layer). `blank_label` names the empty option — the
|
|
1085
|
+
# "inherit the broader layer" / provider-default choice. Values come from a
|
|
1086
|
+
# fixed constant (safe to emit); `current` is only compared, never output.
|
|
1087
|
+
def thinking_select(name, current, blank_label)
|
|
1088
|
+
cur = current.to_s
|
|
1089
|
+
options = [["", blank_label]] + Insika::ModelSelection::THINKING_LEVELS.map { |v| [v, v] }
|
|
1090
|
+
rows = options.map do |value, label|
|
|
1091
|
+
%(<option value="#{value}"#{' selected' if value == cur}>#{label}</option>)
|
|
1092
|
+
end.join
|
|
1093
|
+
%(<select name="#{name}">#{rows}</select>)
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Renders the per-model reasoning map ({ "ref" => { "thinking" => v } }) back to
|
|
1097
|
+
# the textarea lines "ref | thinking" that model_params_patch parses. Only refs
|
|
1098
|
+
# with a set thinking are shown (blank ones are inherit no-ops).
|
|
1099
|
+
def model_params_text(map)
|
|
1100
|
+
return "" unless map.is_a?(Hash)
|
|
1101
|
+
|
|
1102
|
+
map.filter_map do |ref, cfg|
|
|
1103
|
+
t = cfg.is_a?(Hash) ? cfg["thinking"] : nil
|
|
1104
|
+
"#{ref} | #{t}" if t
|
|
1105
|
+
end.join("\n")
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# A guardrails field off the profile (string-keyed by AgentProfile.build).
|
|
1109
|
+
# `default` is returned when the whole config or the key is absent (so a
|
|
1110
|
+
# never-configured agent shows the conservative defaults in the form).
|
|
1111
|
+
def guardrail_field(gr, key, default)
|
|
1112
|
+
return default unless gr.is_a?(Hash)
|
|
1113
|
+
|
|
1114
|
+
gr[key.to_s].nil? ? default : gr[key.to_s]
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# The agent's model fence as newline-joined refs (for the textarea). nil / no
|
|
1118
|
+
# allow list -> "" (no fence).
|
|
1119
|
+
def model_policy_allow(agent)
|
|
1120
|
+
policy = agent.model_policy
|
|
1121
|
+
return "" unless policy.is_a?(Hash)
|
|
1122
|
+
|
|
1123
|
+
Array(policy["allow"]).join("\n")
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# Config patch from the form (native types: memory bool, limits int).
|
|
1127
|
+
# Preserves the existing limits, overwriting only the form's fields.
|
|
1128
|
+
# `model` is OPTIONAL as of v2: blank clears it, so the agent inherits
|
|
1129
|
+
# the platform `default_model` via the ModelResolver — the config form is the
|
|
1130
|
+
# place that surfaces that layering. params/model_policy: see the helpers below.
|
|
1131
|
+
# config_patch/guardrails_patch/guardrail_responses_patch/params_patch/
|
|
1132
|
+
# model_policy_patch/coerce moved to Studio::Forms.
|
|
1133
|
+
|
|
1134
|
+
# --- Skills index --------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
# Master data for the Skills drill-down (list + authored badges + agents),
|
|
1137
|
+
# shared by every skill route so the master pane always renders. @selected
|
|
1138
|
+
# drives the detail pane (nil = none, "" = new, name = edit).
|
|
1139
|
+
# `agent` renders the AGENT's view of the catalog: its own version of each shared
|
|
1140
|
+
# skill, plus whatever is private to it. Absent = the shared catalog.
|
|
1141
|
+
def load_skills_master(agent: nil)
|
|
1142
|
+
catalog = insika[:skill_catalog]
|
|
1143
|
+
@skills = (catalog ? catalog.all(agent: agent) : []).sort_by(&:name)
|
|
1144
|
+
@stored = insika[:skill_store] ? insika[:skill_store].names(agent: agent) : []
|
|
1145
|
+
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
1146
|
+
# Which agents specialized THIS skill — the availability grid shows it, so an
|
|
1147
|
+
# override is discoverable from the shared skill it overrides.
|
|
1148
|
+
@specialized = insika[:skill_store] ? specialized_by : {}
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
# { skill name => [agent ids] } across every agent scope in the store.
|
|
1152
|
+
def specialized_by
|
|
1153
|
+
insika[:skill_store].agents.each_with_object({}) do |agent, acc|
|
|
1154
|
+
insika[:skill_store].names(agent: agent).each { |name| (acc[name] ||= []) << agent }
|
|
1155
|
+
end
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def render_skills_index
|
|
1159
|
+
load_skills_master
|
|
1160
|
+
# Auto-open the first skill (drill-down convention) so the detail pane is
|
|
1161
|
+
# useful on landing; the empty state only shows with an empty catalog.
|
|
1162
|
+
@selected = @skills.first&.name
|
|
1163
|
+
@skill_content = @selected ? skill_source(@selected) : nil
|
|
1164
|
+
view("skills")
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Raw content for the editor: prefer the store (real SKILL.md), otherwise
|
|
1168
|
+
# reconstruct from what the catalog parsed (editing a disk skill
|
|
1169
|
+
# creates an override in the store — Store wins).
|
|
1170
|
+
def skill_source(name, agent: nil)
|
|
1171
|
+
raw = insika[:skill_store]&.get(name, agent: agent)
|
|
1172
|
+
return raw if raw
|
|
1173
|
+
|
|
1174
|
+
skill = insika[:skill_catalog]&.find(name, agent: agent)
|
|
1175
|
+
return new_skill_template(name) unless skill
|
|
1176
|
+
|
|
1177
|
+
# The WHOLE frontmatter, not just name/description: this text seeds the
|
|
1178
|
+
# specialize action and any first edit of a disk skill, and an override that
|
|
1179
|
+
# silently drops `triggers:` turns the agent's deterministic activation off.
|
|
1180
|
+
fm = ["name: #{skill.name}", "description: #{skill.description}"]
|
|
1181
|
+
fm << "triggers: #{skill.triggers.join(', ')}" if Array(skill.triggers).any?
|
|
1182
|
+
fm << "companions: #{skill.companions.join(', ')}" if Array(skill.companions).any?
|
|
1183
|
+
"---\n#{fm.join("\n")}\n---\n\n#{skill.body}\n"
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
def new_skill_template(name = "my-skill")
|
|
1187
|
+
"---\nname: #{name}\ndescription: one sentence about when to use this skill\n---\n\n" \
|
|
1188
|
+
"# #{name}\n\nFull instructions, loaded on demand by the `load_skill` tool.\n"
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# A skill is active for an agent if it has `skills` = nil (all) or the
|
|
1192
|
+
# list includes the name. Used to pre-check the matrix checkboxes.
|
|
1193
|
+
def skill_enabled_for?(profile, skill_name)
|
|
1194
|
+
profile.skills.nil? || Array(profile.skills).map(&:to_s).include?(skill_name.to_s)
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Is this skill always-on for this agent? `skills_eager` is nil/false (none), true
|
|
1198
|
+
# (blanket) or a list — NOT allowlist semantics, so nil must read as false here.
|
|
1199
|
+
def skill_eager_for?(profile, skill_name)
|
|
1200
|
+
spec = profile.skills_eager
|
|
1201
|
+
return true if Insika::Coercion.truthy?(spec)
|
|
1202
|
+
return false if spec.nil? || spec == false
|
|
1203
|
+
|
|
1204
|
+
Array(spec).map(&:to_s).include?(skill_name.to_s)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# The two "left intact" outcomes of :set_skill_agents: an agent whose allowlist (or
|
|
1208
|
+
# eager set) is "all" cannot have ONE name removed from it without materializing an
|
|
1209
|
+
# explicit list, which would be a destructive surprise. Say so instead of silently
|
|
1210
|
+
# doing nothing.
|
|
1211
|
+
def note_skipped(result)
|
|
1212
|
+
return unless result
|
|
1213
|
+
|
|
1214
|
+
parts = []
|
|
1215
|
+
skipped = Array(result[:skipped_all])
|
|
1216
|
+
eager = Array(result[:skipped_eager_all])
|
|
1217
|
+
parts << "#{skipped.size} agent(s) with 'all' skills were left intact" if skipped.any?
|
|
1218
|
+
parts << "#{eager.size} agent(s) with blanket eager were left intact" if eager.any?
|
|
1219
|
+
flash["notice"] = "#{flash['notice']} — #{parts.join(', ')}." if parts.any?
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
# --- Tools matrix --------------------------------------------------------
|
|
1223
|
+
|
|
1224
|
+
def render_tools_matrix
|
|
1225
|
+
@tools = (insika[:tool_catalog]&.all || []).sort_by(&:name)
|
|
1226
|
+
# Names of the DATA-DEFINED tools (editable via the UI). The rest of the catalog are
|
|
1227
|
+
# code tools (allow/deny only). Used to mark and link the editor.
|
|
1228
|
+
@data_tool_names = insika[:tool_store] ? insika[:tool_store].names : []
|
|
1229
|
+
# Stored but NOT in the catalog = the overlay refused the definition and dropped it
|
|
1230
|
+
# (only a stderr warn otherwise). The pane still links its editor, so the panel is
|
|
1231
|
+
# where you see it and where you fix it. `insika doctor` reports the same set.
|
|
1232
|
+
@dropped_tool_names = @data_tool_names - @tools.map(&:name)
|
|
1233
|
+
@agents = insika[:profile_source].all.sort_by(&:id)
|
|
1234
|
+
# Drill-down: ?a= selects the agent whose allow/deny matrix fills the detail;
|
|
1235
|
+
# default to the first agent so the pane is useful on landing.
|
|
1236
|
+
sel = request.params["a"]
|
|
1237
|
+
@sel_agent = (sel && @agents.find { |a| a.id == sel }) || @agents.first
|
|
1238
|
+
view("tools")
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# nil = all; otherwise the list. Pre-checks the checkboxes per agent.
|
|
1242
|
+
def tool_allowed_for?(profile, tool_name)
|
|
1243
|
+
profile.tools_allow.nil? || Array(profile.tools_allow).map(&:to_s).include?(tool_name.to_s)
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
# A tool the agent explicitly denies. Deny ALWAYS wins over allow, so the matrix
|
|
1247
|
+
# renders these "locked" (disabled) — you can't grant a denied tool from here.
|
|
1248
|
+
def tool_denied_for?(profile, tool_name)
|
|
1249
|
+
Array(profile.tools_deny).map(&:to_s).include?(tool_name.to_s)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# `checked/total on` for an agent's tool group (open allowlist -> total). Feeds
|
|
1253
|
+
# the initial server-rendered counter; the toggle-counter island keeps it live.
|
|
1254
|
+
def tools_on_count(profile, tools)
|
|
1255
|
+
total = tools.size
|
|
1256
|
+
return total if profile.tools_allow.nil?
|
|
1257
|
+
|
|
1258
|
+
allow = Array(profile.tools_allow).map(&:to_s)
|
|
1259
|
+
tools.count { |t| allow.include?(t.name.to_s) && !tool_denied_for?(profile, t.name) }
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# Data-defined tool authoring -------------------------------
|
|
1263
|
+
|
|
1264
|
+
def render_tool_edit(name:, tool:)
|
|
1265
|
+
@tool_name = name
|
|
1266
|
+
@form = tool_form(tool)
|
|
1267
|
+
@versions = tool && insika[:tool_store] ? insika[:tool_store].versions(name) : []
|
|
1268
|
+
view("tool_edit")
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
# Definition (masked) -> Hash of text fields ready for the form. tool=nil
|
|
1272
|
+
# (new) -> defaults. Mirrors env_lines/param_lines for headers/query/params.
|
|
1273
|
+
def tool_form(tool)
|
|
1274
|
+
t = tool || {}
|
|
1275
|
+
req = t["request"] || {}
|
|
1276
|
+
resp = t["response"] || {}
|
|
1277
|
+
{
|
|
1278
|
+
name: t["name"].to_s, description: t["description"].to_s,
|
|
1279
|
+
method: req["method"] || "GET", url: req["url"].to_s,
|
|
1280
|
+
parameters: params_text(t["parameters"]),
|
|
1281
|
+
query: env_lines(req["query"]), headers: env_lines(req["headers"]),
|
|
1282
|
+
secret_headers: Array(t["secret_headers"]).join(", "),
|
|
1283
|
+
body: req["body"].to_s,
|
|
1284
|
+
extract: resp["extract"] || "body_raw", path: resp["path"].to_s,
|
|
1285
|
+
timeout: t["timeout"]
|
|
1286
|
+
}
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
# tool_patch/parse_parameters moved to Studio::Forms.
|
|
1290
|
+
|
|
1291
|
+
# Inverse of parse_parameters: the stored params -> text for the textarea. A schema
|
|
1292
|
+
# the flat sugar can express round-trips as pipe lines (the friendly form); anything
|
|
1293
|
+
# NESTED renders as JSON Schema — the same text the form parses back, so opening and
|
|
1294
|
+
# saving a nested tool is a no-op instead of a silent flattening.
|
|
1295
|
+
def params_text(params)
|
|
1296
|
+
return param_lines(params) if flat_sugar?(params)
|
|
1297
|
+
|
|
1298
|
+
JSON.pretty_generate(params)
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
def param_lines(params)
|
|
1302
|
+
flat_params(params).map do |p|
|
|
1303
|
+
req = p["required"] == false ? "optional" : "required"
|
|
1304
|
+
"#{p['name']} | #{p['type']} | #{req} | #{p['description']}"
|
|
1305
|
+
end.join("\n")
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
# JSON Schema (Hash) OR flat array -> top-level [{name,type,required,description}].
|
|
1309
|
+
# An array property renders with its item type (`array:string`) — the spelling the
|
|
1310
|
+
# sugar accepts back. A legacy record with a bare `array` renders as `array:string`
|
|
1311
|
+
# too: that IS what it meant, now written down (see ToolDefinition.flat_property).
|
|
1312
|
+
def flat_params(params)
|
|
1313
|
+
if params.is_a?(Hash)
|
|
1314
|
+
props = params["properties"] || {}
|
|
1315
|
+
required = Array(params["required"]).map(&:to_s)
|
|
1316
|
+
props.map do |name, schema|
|
|
1317
|
+
schema ||= {}
|
|
1318
|
+
{ "name" => name.to_s, "type" => flat_type(schema),
|
|
1319
|
+
"required" => required.include?(name.to_s), "description" => schema["description"].to_s }
|
|
1320
|
+
end
|
|
1321
|
+
else
|
|
1322
|
+
Array(params).map { |p| p.is_a?(Hash) ? p.merge("type" => flat_type_from_legacy(p)) : p }
|
|
1323
|
+
end
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Can the flat textarea express this schema without losing anything? Only if every
|
|
1327
|
+
# top-level property is a scalar (or a list of scalars) and carries no keyword the
|
|
1328
|
+
# sugar cannot write back (nested properties, enum, minItems…).
|
|
1329
|
+
def flat_sugar?(params)
|
|
1330
|
+
return true unless params.is_a?(Hash)
|
|
1331
|
+
return false unless (params.keys - %w[type properties required]).empty?
|
|
1332
|
+
|
|
1333
|
+
(params["properties"] || {}).all? { |_, schema| flat_sugar_property?(schema) }
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def flat_sugar_property?(schema)
|
|
1337
|
+
return false unless schema.is_a?(Hash)
|
|
1338
|
+
return false unless (schema.keys - %w[type description items]).empty?
|
|
1339
|
+
|
|
1340
|
+
type = schema["type"].to_s
|
|
1341
|
+
return Insika::ToolDefinition::PARAM_TYPES.include?(type) unless type == "array"
|
|
1342
|
+
|
|
1343
|
+
items = schema["items"]
|
|
1344
|
+
items.is_a?(Hash) && items.keys == ["type"] &&
|
|
1345
|
+
Insika::ToolDefinition::PARAM_TYPES.include?(items["type"].to_s)
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
def flat_type(schema)
|
|
1349
|
+
type = (schema["type"] || "string").to_s
|
|
1350
|
+
return type unless type == "array"
|
|
1351
|
+
|
|
1352
|
+
"array:#{schema.dig('items', 'type') || 'string'}"
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
def flat_type_from_legacy(param)
|
|
1356
|
+
type = (param["type"] || "string").to_s
|
|
1357
|
+
type == "array" ? "array:string" : type
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
def tool_def_path(name) = "/studio/tools/def/#{Rack::Utils.escape(name.to_s)}"
|
|
1361
|
+
|
|
1362
|
+
# --- Overview / home ------------------------------------------------------
|
|
1363
|
+
|
|
1364
|
+
# At-a-glance dashboard: counts + activity + recent conversations. All from
|
|
1365
|
+
# data the Studio already reads (one scan of the session store); no new
|
|
1366
|
+
# metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
|
|
1367
|
+
def render_home
|
|
1368
|
+
ps = insika[:profile_source]
|
|
1369
|
+
sessions = all_sessions
|
|
1370
|
+
@counts = {
|
|
1371
|
+
"conversations" => sessions.size,
|
|
1372
|
+
"messages" => sessions.sum { |s| Array(s.messages).size },
|
|
1373
|
+
"agents" => ps ? ps.all.size : 0,
|
|
1374
|
+
"skills" => insika[:skill_catalog] ? insika[:skill_catalog].all.size : 0,
|
|
1375
|
+
"tools" => insika[:tool_catalog] ? insika[:tool_catalog].all.size : 0,
|
|
1376
|
+
"providers" => insika[:llm_provider_store] ? insika[:llm_provider_store].all.size : 0,
|
|
1377
|
+
"MCP servers" => insika[:mcp_store] ? insika[:mcp_store].all.size : 0
|
|
1378
|
+
}
|
|
1379
|
+
now = Time.now
|
|
1380
|
+
cutoff = now - (5 * 60)
|
|
1381
|
+
@active_now = sessions.count { |s| (t = parse_time(s.updated_at)) && t >= cutoff }
|
|
1382
|
+
@recent = sessions.sort_by { |s| s.updated_at.to_s }.reverse.first(8)
|
|
1383
|
+
@activity = activity_by_day(sessions, days: 14, now: now)
|
|
1384
|
+
@persistence = insika.dig(:config, :persistence)
|
|
1385
|
+
view("home")
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
def all_sessions
|
|
1389
|
+
store = insika[:session_store]
|
|
1390
|
+
return [] unless store
|
|
1391
|
+
|
|
1392
|
+
store.each_id.filter_map { |sid| store.find(sid) }
|
|
1393
|
+
end
|
|
1394
|
+
|
|
1395
|
+
def parse_time(str)
|
|
1396
|
+
s = str.to_s
|
|
1397
|
+
return nil if s.empty?
|
|
1398
|
+
|
|
1399
|
+
Time.parse(s)
|
|
1400
|
+
rescue ArgumentError
|
|
1401
|
+
nil
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
# [[Date, count], …] — one bucket per day over the window, most-recent last.
|
|
1405
|
+
def activity_by_day(sessions, days:, now:)
|
|
1406
|
+
today = now.to_date
|
|
1407
|
+
buckets = Hash.new(0)
|
|
1408
|
+
sessions.each do |s|
|
|
1409
|
+
t = parse_time(s.updated_at) or next
|
|
1410
|
+
buckets[t.to_date] += 1
|
|
1411
|
+
end
|
|
1412
|
+
(0...days).to_a.reverse.map { |i| d = today - i; [d, buckets[d]] }
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
# --- History -------------------------------------------------------------
|
|
1416
|
+
|
|
1417
|
+
# Recent conversations (all agents — the Session doesn't stamp the agent that
|
|
1418
|
+
# produced it). Most recent first, capped.
|
|
1419
|
+
def recent_sessions(limit: 8)
|
|
1420
|
+
store = insika[:session_store]
|
|
1421
|
+
return [] unless store
|
|
1422
|
+
|
|
1423
|
+
store.each_id.filter_map { |sid| store.find(sid) }
|
|
1424
|
+
.sort_by { |s| s.updated_at.to_s }.reverse.first(limit)
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
# Compact relative age ("just now", "9min", "3h", "2d") for a timestamp string.
|
|
1428
|
+
def time_ago(str)
|
|
1429
|
+
t = parse_time(str) or return str.to_s
|
|
1430
|
+
secs = (Time.now - t).to_i
|
|
1431
|
+
return "just now" if secs < 60
|
|
1432
|
+
|
|
1433
|
+
mins = secs / 60
|
|
1434
|
+
return "#{mins}min" if mins < 60
|
|
1435
|
+
|
|
1436
|
+
hrs = mins / 60
|
|
1437
|
+
return "#{hrs}h" if hrs < 24
|
|
1438
|
+
|
|
1439
|
+
"#{hrs / 24}d"
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
def session_preview(session)
|
|
1443
|
+
last = Array(session.messages).reverse.find { |m| %w[user assistant].include?(m["role"]) }
|
|
1444
|
+
last && last["content"].to_s
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
# --- Settings + LLM providers ----------------------------------
|
|
1448
|
+
|
|
1449
|
+
SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
|
|
1450
|
+
def render_settings
|
|
1451
|
+
store = insika[:settings_store]
|
|
1452
|
+
@settings = store ? store.get : Insika::SettingsStore::DEFAULTS
|
|
1453
|
+
@providers = insika[:llm_provider_store] ? insika[:llm_provider_store].all : []
|
|
1454
|
+
@section = SETTINGS_SECTIONS.include?(request.params["s"]) ? request.params["s"] : "general"
|
|
1455
|
+
view("settings")
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
# Settings patch from the form. streaming is a bool (checkbox); the timeouts
|
|
1459
|
+
# are integers. Only what came in the form enters the patch (the rest and
|
|
1460
|
+
# the defaults are preserved in the store).
|
|
1461
|
+
# settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms.
|
|
1462
|
+
|
|
1463
|
+
# --- MCP -------------------------------------------------------
|
|
1464
|
+
|
|
1465
|
+
def render_mcp
|
|
1466
|
+
@instances = insika[:mcp_store] ? insika[:mcp_store].all : []
|
|
1467
|
+
view("mcp")
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
# mcp_patch moved to Studio::Forms.
|
|
1471
|
+
|
|
1472
|
+
# --- System-files ----------------------------------------------
|
|
1473
|
+
|
|
1474
|
+
def render_system_files
|
|
1475
|
+
store = insika[:system_file_store]
|
|
1476
|
+
names = store ? store.list : []
|
|
1477
|
+
@system_files = names.map do |name|
|
|
1478
|
+
{ name: name, content: store.read(name).to_s, versions: store.versions(name) }
|
|
1479
|
+
end
|
|
1480
|
+
view("system_files")
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# --- Chats -----------------------------------------------------
|
|
1484
|
+
|
|
1485
|
+
def render_chats
|
|
1486
|
+
@sessions = recent_sessions(limit: 100)
|
|
1487
|
+
view("chats")
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
# Tasks & Approvals --------------------------------
|
|
1491
|
+
|
|
1492
|
+
# Task list, most-recently-updated first. Empty-state if no store was injected.
|
|
1493
|
+
def render_tasks
|
|
1494
|
+
store = insika[:task_store]
|
|
1495
|
+
@tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
|
|
1496
|
+
@tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
|
|
1497
|
+
view("tasks")
|
|
1498
|
+
end
|
|
1499
|
+
|
|
1500
|
+
# Task detail: @task is set by the route. Adds the open approvals for this task
|
|
1501
|
+
# and its latest checkpoint (both degrade to empty when the store is absent).
|
|
1502
|
+
def render_task_detail(id)
|
|
1503
|
+
@pending = insika[:pending_action_store] ? insika[:pending_action_store].open_for(id) : []
|
|
1504
|
+
@checkpoint = insika[:checkpoint_store]&.latest(id)
|
|
1505
|
+
view("task")
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# Approvals inbox: every :pending action across tasks, each paired with its
|
|
1509
|
+
# task (for the status pill + a link into the task detail).
|
|
1510
|
+
def render_approvals
|
|
1511
|
+
pstore = insika[:pending_action_store]
|
|
1512
|
+
tstore = insika[:task_store]
|
|
1513
|
+
@approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
|
|
1514
|
+
view("approvals")
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
# Evals -------------------------------------
|
|
1518
|
+
|
|
1519
|
+
# The stored cases, grouped by agent, plus the one being edited (?id=). Cases whose
|
|
1520
|
+
# stored mapping no longer validates are listed separately: a broken case must be
|
|
1521
|
+
# visible, because a run silently skips it.
|
|
1522
|
+
def render_evals
|
|
1523
|
+
store = insika[:golden_store]
|
|
1524
|
+
@cases = store ? store.all : []
|
|
1525
|
+
@invalid = store ? store.invalid : []
|
|
1526
|
+
@by_agent = @cases.group_by(&:agent).sort.to_h
|
|
1527
|
+
wanted = presence(request.params["id"])
|
|
1528
|
+
@case = wanted && @cases.find { |g| g.id == wanted }
|
|
1529
|
+
@case_yaml = @case ? golden_yaml(@case) : nil
|
|
1530
|
+
view("evals")
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1533
|
+
# The case as the YAML an operator edits — the same shape `evals/golden/**` holds,
|
|
1534
|
+
# so there is one format to learn and a pull request can review what was authored.
|
|
1535
|
+
def golden_yaml(golden)
|
|
1536
|
+
h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
|
|
1537
|
+
h["requires"] = golden.requires unless golden.requires.empty? # dropping it would un-skip the case
|
|
1538
|
+
h["reference"] = golden.reference unless golden.reference.empty? # …and this would un-compare it
|
|
1539
|
+
YAML.dump(h.merge("expect" => golden.expect))
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
# Refinement -----------------------------
|
|
1543
|
+
|
|
1544
|
+
# The agent's latest failure report + its run history. Empty-state when no store
|
|
1545
|
+
# was injected or the agent has never been run — the page is the invitation to
|
|
1546
|
+
# run it, so there is nothing to hide behind a nil.
|
|
1547
|
+
def render_refinement
|
|
1548
|
+
@agents = insika[:profile_source].ids.sort
|
|
1549
|
+
@agent = presence(request.params["agent"]) || @agents.first
|
|
1550
|
+
store = insika[:refinement_store]
|
|
1551
|
+
@runs = @agent && store ? store.for_agent(@agent, limit: 10) : []
|
|
1552
|
+
@run = @runs.find(&:terminal?)
|
|
1553
|
+
# The one run that owes this agent a human answer. At most one is possible:
|
|
1554
|
+
# gating requires a `completed` run and there is one lifecycle per record.
|
|
1555
|
+
@proposal = @runs.find(&:awaiting_approval?)
|
|
1556
|
+
view("refinement")
|
|
1557
|
+
end
|
|
1558
|
+
|
|
1559
|
+
# A run's window in words. An EMPTY window means "the collector's own default" —
|
|
1560
|
+
# resolve it for the operator instead of rendering a blank.
|
|
1561
|
+
def refinement_window(window)
|
|
1562
|
+
return "since #{window['since']}" if window["since"]
|
|
1563
|
+
|
|
1564
|
+
"last #{window['last_sessions'] || Insika::Refinement::EvidenceCollector::DEFAULT_WINDOW} conversation(s)"
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1567
|
+
# An operator control (pause/resume/cancel/approve): audits the ATTEMPT to the
|
|
1568
|
+
# shared EventStream BEFORE dispatching — accountability survives a Command
|
|
1569
|
+
# failure (parity with server/admin's `act`) — then dispatches via the bus with
|
|
1570
|
+
# a success/error flash. The audit carries only metadata, never message/args —
|
|
1571
|
+
# an event reaches every subscriber of the stream, so it stays free of content.
|
|
1572
|
+
def control_action(type, payload, ok:)
|
|
1573
|
+
emit_operator_action(type, payload)
|
|
1574
|
+
with_flash(ok) { dispatch(type, payload) }
|
|
1575
|
+
end
|
|
1576
|
+
|
|
1577
|
+
def emit_operator_action(type, payload)
|
|
1578
|
+
stream = insika[:event_stream]
|
|
1579
|
+
return unless stream
|
|
1580
|
+
|
|
1581
|
+
stream.emit(Insika::Event.new(
|
|
1582
|
+
type: :operator_action,
|
|
1583
|
+
data: { action: type.to_s,
|
|
1584
|
+
target: payload.slice(:task_id, :pending_id, :decision, :agent),
|
|
1585
|
+
operator: operator_label },
|
|
1586
|
+
meta: { task_id: payload[:task_id], at: Time.now.utc.iso8601 }
|
|
1587
|
+
))
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
# Operator identity for the audit/approval (single admin token → the Studio
|
|
1591
|
+
# is the operator). Mirrors server/admin's operator_of default.
|
|
1592
|
+
def operator_label = "studio"
|
|
1593
|
+
|
|
1594
|
+
# Semantic status class for the .pill CSS (completed=ok, running=run,
|
|
1595
|
+
# waiting/queued/paused=warn, failed/cancelled=err). Parity with admin's
|
|
1596
|
+
# status_pill mapping; used by the tasks/approvals views.
|
|
1597
|
+
def status_class(status)
|
|
1598
|
+
case status.to_s
|
|
1599
|
+
when "completed" then "ok"
|
|
1600
|
+
when "running" then "run"
|
|
1601
|
+
when "waiting", "queued", "paused" then "warn"
|
|
1602
|
+
when "failed", "cancelled" then "err"
|
|
1603
|
+
else "info"
|
|
1604
|
+
end
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
def task_path(id) = "/studio/tasks/#{Rack::Utils.escape(id.to_s)}"
|
|
1608
|
+
|
|
1609
|
+
# parse_kv_lines moved to Studio::Forms.
|
|
1610
|
+
|
|
1611
|
+
# CSV/whitespace -> [String], blanks dropped.
|
|
1612
|
+
def split_list(str)
|
|
1613
|
+
str.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?)
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# --- Playground ----------------------------------------------------------
|
|
1617
|
+
|
|
1618
|
+
# Dispatches the send_message through the SAME bus as the API (no direct writes to a
|
|
1619
|
+
# store). tenant = agent → the turn's memory is the agent's (parity with the
|
|
1620
|
+
# memory page). The UI observes the turn via SSE.
|
|
1621
|
+
def dispatch_send_message(agent:, session_id:, message:)
|
|
1622
|
+
dispatch(:send_message, { agent: agent, session_id: session_id, message: message }, tenant: agent)
|
|
1623
|
+
end
|
|
1624
|
+
|
|
1625
|
+
# Creates a new session via the bus (create_session generates the id) and returns
|
|
1626
|
+
# the id. `model`/`provider` (optional) become the per-chat pin: CreateSession
|
|
1627
|
+
# stashes them in the reserved `vars["__llm__"]` slot the ModelResolver reads as
|
|
1628
|
+
# the highest-precedence layer (Chat > Agent > platform default). Only non-blank
|
|
1629
|
+
# values are sent, so an empty override leaves the session unpinned.
|
|
1630
|
+
def create_session(model: nil, provider: nil, thinking: nil)
|
|
1631
|
+
payload = { vars: { "canal" => "studio" } }
|
|
1632
|
+
payload[:model] = model if model
|
|
1633
|
+
payload[:provider] = provider if provider
|
|
1634
|
+
payload[:thinking] = thinking if thinking
|
|
1635
|
+
dispatch(:create_session, payload).id
|
|
1636
|
+
end
|
|
1637
|
+
|
|
1638
|
+
def playground_path(agent, session_id)
|
|
1639
|
+
query = "agent=#{Rack::Utils.escape(agent)}"
|
|
1640
|
+
query += "&session_id=#{Rack::Utils.escape(session_id)}" if session_id
|
|
1641
|
+
"/studio/playground?#{query}"
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
def agent_path(id, anchor = nil)
|
|
1645
|
+
base = "/studio/agents/#{Rack::Utils.escape(id)}"
|
|
1646
|
+
anchor ? "#{base}##{anchor}" : base
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
# Serves a versioned asset from dist. `File.basename` kills path traversal; only
|
|
1650
|
+
# files that exist in the dist dir are served.
|
|
1651
|
+
def serve_asset(name)
|
|
1652
|
+
base = File.basename(name)
|
|
1653
|
+
path = File.join(ASSETS_DIR, base)
|
|
1654
|
+
unless File.file?(path) && File.fnmatch(File.join(ASSETS_DIR, "*"), path)
|
|
1655
|
+
response.status = 404
|
|
1656
|
+
return "not found"
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
response["content-type"] = CONTENT_TYPES.fetch(File.extname(base), "application/octet-stream")
|
|
1660
|
+
# no-cache (revalidate every load) + the ?v=mtime bust in asset_path: a
|
|
1661
|
+
# rebuilt CSS/JS is NEVER served stale, even mid-session across restarts.
|
|
1662
|
+
# Assets are tiny and same-origin, so the revalidation cost is negligible —
|
|
1663
|
+
# correctness over caching for an actively-edited admin UI.
|
|
1664
|
+
response["cache-control"] = "no-cache"
|
|
1665
|
+
File.read(path)
|
|
1666
|
+
end
|
|
1667
|
+
|
|
1668
|
+
# Cache-busting URL for a dist asset. Dist files are served under a STABLE
|
|
1669
|
+
# name with max-age=300, so a rebuilt CSS/JS stays masked by the browser
|
|
1670
|
+
# cache for 5 min — even across a server restart (the "restarted and it's
|
|
1671
|
+
# still broken" trap). Appending the file mtime as ?v= changes the URL whenever the
|
|
1672
|
+
# asset changes, so the browser always refetches the fresh build. The query
|
|
1673
|
+
# is ignored by serve_asset (it matches on the path segment).
|
|
1674
|
+
def asset_path(file)
|
|
1675
|
+
base = File.basename(file)
|
|
1676
|
+
path = File.join(ASSETS_DIR, base)
|
|
1677
|
+
v = File.file?(path) ? File.mtime(path).to_i : 0
|
|
1678
|
+
"/studio/assets/dist/#{base}?v=#{v}"
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
1682
|
+
|
|
1683
|
+
# Masked-secret sentinel (to pre-fill credential fields in the
|
|
1684
|
+
# forms: resubmitting without touching preserves the real secret in the store).
|
|
1685
|
+
def secret_sentinel = Insika::SecretMasking::SENTINEL
|
|
1686
|
+
|
|
1687
|
+
# An MCP instance's env (already MASKED) -> "KEY=value" text per line,
|
|
1688
|
+
# for the textarea. Sorts by key (stable across renders).
|
|
1689
|
+
def env_lines(env)
|
|
1690
|
+
(env || {}).sort.map { |k, v| "#{k}=#{v}" }.join("\n")
|
|
1691
|
+
end
|
|
1692
|
+
end
|
|
1693
|
+
end
|