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