insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -30,8 +30,8 @@ module Studio
30
30
  # Same-origin assets: versioned esbuild bundle in `assets/dist/*`, served
31
31
  # by `/studio/assets/dist/*`. Strict `'self'` CSP (no `unsafe-inline`).
32
32
  class App < Roda
33
- include Forms # form -> command-payload parsing (§11 B6)
34
- include NavIcons # nav SVG helper (§11 B6)
33
+ include Forms # form -> command-payload parsing
34
+ include NavIcons # nav SVG helper
35
35
 
36
36
  # The session cookie lives N days. 7 days = parity with the OpenClaw default.
37
37
  SESSION_MAX_AGE = 7 * 24 * 3600
@@ -81,36 +81,84 @@ module Studio
81
81
  def configure(command_bus:, profile_source:, event_stream:, config:,
82
82
  agent_file_store: nil, skill_store: nil, skill_catalog: nil,
83
83
  tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
84
+ memory_audit_store: nil,
84
85
  settings_store: nil, llm_provider_store: nil, mcp_store: nil,
85
- system_file_store: nil, tool_trace_store: nil,
86
+ system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
87
+ cache_series_store: nil,
86
88
  task_store: nil, checkpoint_store: nil, pending_action_store: nil,
87
- refinement_store: nil, golden_store: nil, session_secret: nil)
89
+ refinement_store: nil, golden_store: nil, session_secret: nil,
90
+ outcome_store: nil, shadow_pair_store: nil, parity_criterion: nil,
91
+ funnel_store: nil, budget_ledger: nil,
92
+ followup_store: nil, contact_store: nil,
93
+ proposal_store: nil,
94
+ harvest_store: nil, harvest_criterion: nil, negative_list: nil,
95
+ channel_registry: nil)
88
96
  @insika = {
89
97
  command_bus: command_bus, profile_source: profile_source,
90
98
  event_stream: event_stream, config: config,
91
99
  agent_file_store: agent_file_store, skill_store: skill_store,
92
100
  skill_catalog: skill_catalog, tool_catalog: tool_catalog,
93
- # tool_store: DATA-DEFINED tool definitions (Phase 5). The catalog already
101
+ # tool_store: DATA-DEFINED tool definitions. The catalog already
94
102
  # shows the data-tools in the matrix; the store feeds the authoring page.
95
103
  tool_store: tool_store,
96
104
  memory_store: memory_store, session_store: session_store,
105
+ # the Customers drill renders the audit lines (digests +
106
+ # counts, never content). nil = the audit section renders empty.
107
+ memory_audit_store: memory_audit_store,
97
108
  # runtime config (settings/LLM/MCP) + global system
98
109
  # files + conversations index. All optional (empty-state if nil).
99
110
  settings_store: settings_store, llm_provider_store: llm_provider_store,
100
111
  mcp_store: mcp_store, system_file_store: system_file_store,
101
112
  # per-session tool-call trace (debug): args + result + status per
102
- # turn, rendered in the session viewer (FOLLOWUP §3.1).
113
+ # turn, rendered in the session viewer.
103
114
  tool_trace_store: tool_trace_store,
104
- # operate: tasks + human-in-the-loop approvals (§12 G5). Reads the
115
+ # per-session context breakdown: tokens by category +
116
+ # budget per turn, on the same viewer. Counts only, no content.
117
+ context_trace_store: context_trace_store,
118
+ # per-AGENT cache-hit series (percentages + counts, no
119
+ # content) — the cache tab on the agent detail. nil = tab renders
120
+ # "No turns recorded.".
121
+ cache_series_store: cache_series_store,
122
+ # operate: tasks + human-in-the-loop approvals. Reads the
105
123
  # task/checkpoint/pending stores to render; controls (pause/resume/
106
124
  # cancel/approve) dispatch on the bus — parity with server/admin.
107
125
  task_store: task_store, checkpoint_store: checkpoint_store,
108
126
  pending_action_store: pending_action_store,
109
- # refinement runs (RFC-0013 phase A): the ranked failure report per agent.
127
+ # refinement runs: the ranked failure report per agent.
110
128
  # Read-only here; the "Run" button dispatches :run_refinement on the bus.
111
129
  refinement_store: refinement_store,
112
- # eval cases (RFC-0008 §3.1): read to render; writes go through :write_golden.
113
- golden_store: golden_store
130
+ # eval cases: read to render; writes go through:write_golden.
131
+ golden_store: golden_store,
132
+ # WS7: business outcomes — the agents grid's last-outcome pill; the
133
+ # agent detail shows the per-day series. Reads only; recording goes
134
+ # through POST /v1/outcomes.
135
+ outcome_store: outcome_store,
136
+ # the Funnel page reads the fold's cells and the
137
+ # BudgetLedger's current counters (D6). nil = the page renders its
138
+ # empty state.
139
+ funnel_store: funnel_store, budget_ledger: budget_ledger,
140
+ # the shadow pair store (read to render /studio/parity), the
141
+ # frozen criterion (folded by Verdict), and the channel registry (the
142
+ # nav row only exists when a shadow channel is registered — the page
143
+ # is not an invitation, unlike Refinement's).
144
+ shadow_pair_store: shadow_pair_store, parity_criterion: parity_criterion,
145
+ channel_registry: channel_registry,
146
+ # the Follow-ups page READS the stores directly;
147
+ # its only mutations — cancel a pending record, force-revoke a contact
148
+ # — dispatch :cancel_followup / :revoke_contact on the bus. nil = the
149
+ # page renders its empty state.
150
+ followup_store: followup_store, contact_store: contact_store,
151
+ # the Facts (wiki) page reads the proposal store directly
152
+ # (approvals/rejections dispatch :resolve_proposal on the bus).
153
+ # nil = the page renders its empty state.
154
+ proposal_store: proposal_store,
155
+ # the Harvest page reads the harvest store directly
156
+ # (the pending/awaiting/promoted lists); its mutations — mine, gate,
157
+ # promote, reject, rollback — dispatch bus commands. The criterion
158
+ # (boot-loaded, with its sha) and the negative list render the two
159
+ # pre-registered artifacts. nil = the page renders its empty state.
160
+ harvest_store: harvest_store, harvest_criterion: harvest_criterion,
161
+ negative_list: negative_list
114
162
  }.freeze
115
163
  # "restart recommended" flag — in memory, PER PROCESS. A
116
164
  # config change that the runtime only re-reads at boot (e.g.: MCP instances are
@@ -230,11 +278,15 @@ module Studio
230
278
  r.is do
231
279
  r.get do
232
280
  @agents = insika[:profile_source].all.sort_by(&:id)
281
+ # WS7 scorecard: the LAST outcome per agent, computed once for the
282
+ # whole grid (a store scan per card would be n scans). Series live
283
+ # on the agent detail — one agent's periods, not n charts here.
284
+ @latest_outcomes = insika[:outcome_store]&.latest_per_agent
233
285
  view("agents")
234
286
  end
235
287
 
236
288
  # POST /studio/agents — creates an agent ("everyone creates
237
- # their own BIA"). Fires :create_agent; redirects to the new one's detail.
289
+ # their own agent"). Fires :create_agent; redirects to the new one's detail.
238
290
  r.post do
239
291
  check_csrf!
240
292
  id = presence(r.params["id"])
@@ -266,20 +318,86 @@ module Studio
266
318
  with_flash("Configuration saved.") do
267
319
  dispatch(:update_agent, config_patch(r))
268
320
  end
269
- r.redirect(agent_path(id))
321
+ group = CONFIG_SECTIONS.include?(r.params["cfg"]) ? r.params["cfg"] : "model"
322
+ r.redirect(agent_path(id, "config", "cfg=#{group}"))
323
+ end
324
+
325
+ # Automatic-loop triggers, per agent. The loops (distillation,
326
+ # harvest, follow-up firing, funnel fold) already run on their own
327
+ # worker fibers when the agent declares them; these buttons exist so
328
+ # an operator can ask for a pass NOW, without waiting for the
329
+ # window. All three dispatch bus commands — the Studio never runs a
330
+ # loop itself.
331
+ r.post "distill" do
332
+ check_csrf!
333
+ done = 0
334
+ skipped = 0
335
+ begin
336
+ due_distill_sessions(id).each do |sid|
337
+ result = dispatch(:run_distillation, { session_id: sid })
338
+ if result && result[:distilled]
339
+ done += 1
340
+ else
341
+ skipped += 1
342
+ end
343
+ end
344
+ flash["notice"] = "Distilled #{done} session(s)" + (skipped.positive? ? " (#{skipped} skipped)." : ".")
345
+ rescue Insika::ValidationError, Insika::NotFoundError => e
346
+ flash["error"] = e.message
347
+ end
348
+ r.redirect(agent_path(id, "loops"))
349
+ end
350
+
351
+ r.post "harvest" do
352
+ check_csrf!
353
+ begin
354
+ result = dispatch(:run_harvest, { agent: id })
355
+ if result && result[:mined]
356
+ flash["notice"] = "Harvest run finished — #{result[:candidates]} candidate(s) mined."
357
+ elsif result && result[:skipped]
358
+ flash["notice"] = "Nothing mined — #{result[:skipped]}."
359
+ else
360
+ flash["notice"] = "Harvest run finished."
361
+ end
362
+ rescue Insika::ValidationError, Insika::NotFoundError => e
363
+ flash["error"] = e.message
364
+ end
365
+ r.redirect(agent_path(id, "loops"))
366
+ end
367
+
368
+ r.post "refinement" do
369
+ check_csrf!
370
+ with_flash("Refinement run finished — see the Refinement page.") do
371
+ dispatch(:run_refinement, { agent: id })
372
+ end
373
+ r.redirect(agent_path(id, "loops"))
270
374
  end
271
375
 
272
376
  # Store-backed prompts. Writing also ensures the file
273
377
  # enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
274
378
  # prompt_files is synced by the Commands themselves (write/delete
275
379
  # register/remove the file) — the Studio just dispatches the operation.
380
+ #
381
+ # The prompts section renders as a drill (master file list | editor), so
382
+ # the file under edit rides the URL like skills does: GET .../prompts/:file
383
+ # opens that file, .../prompts/new opens the create form.
276
384
  r.on "prompts" do
385
+ r.get "new" do
386
+ @prompt_selected = ""
387
+ render_agent_detail
388
+ end
389
+
390
+ r.get String do |file|
391
+ @prompt_selected = utf8(file)
392
+ render_agent_detail
393
+ end
394
+
277
395
  r.post "delete" do
278
396
  check_csrf!
279
397
  with_flash("Prompt removed.") do
280
398
  dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
281
399
  end
282
- r.redirect(agent_path(id))
400
+ r.redirect(agent_path(id, "prompts"))
283
401
  end
284
402
 
285
403
  r.post "restore" do
@@ -290,7 +408,7 @@ module Studio
290
408
  version: r.params["version"]
291
409
  })
292
410
  end
293
- r.redirect(agent_path(id))
411
+ r.redirect(prompt_edit_path(id, r.params["file"]))
294
412
  end
295
413
 
296
414
  r.post do
@@ -300,24 +418,62 @@ module Studio
300
418
  agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
301
419
  })
302
420
  end
303
- r.redirect(agent_path(id))
421
+ r.redirect(prompt_edit_path(id, r.params["file"]))
304
422
  end
305
423
  end
306
424
 
307
- # Agent skills → :update_agent with the `skills` allowlist.
308
- # "all" = nil; otherwise the checked subset (possibly []).
309
- r.post "skills" do
310
- check_csrf!
311
- skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
312
- with_flash("Skills updated.") do
313
- dispatch(:update_agent, { id: id, skills: skills })
425
+ r.on "skills" do
426
+ # POST /studio/agents/:id/skills → :update_agent with the `skills`
427
+ # allowlist. "all" = nil; otherwise the checked subset (possibly []).
428
+ r.is do
429
+ r.post do
430
+ check_csrf!
431
+ skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
432
+ with_flash("Skills updated.") do
433
+ dispatch(:update_agent, { id: id, skills: skills })
434
+ end
435
+ r.redirect(agent_path(id))
436
+ end
437
+ end
438
+
439
+ # /studio/agents/:id/skills/:name — this agent's OWN version of a skill.
440
+ # TWO path segments, which is exactly why the store takes the agent as a
441
+ # second argument: a composite "agent/name" key would put a `/` inside
442
+ # what this route serves as one segment.
443
+ r.on String do |name|
444
+ name = utf8(name)
445
+ r.is do
446
+ r.get do
447
+ load_skills_master(agent: id)
448
+ @selected = name
449
+ @scope_agent = id
450
+ @skill_content = skill_source(name, agent: id)
451
+ view("skills")
452
+ end
453
+ # Saving the specialization: same command, `agent:` set.
454
+ r.post do
455
+ check_csrf!
456
+ with_flash("Specialization saved.") do
457
+ dispatch(:write_skill, { name: name, agent: id, content: r.params["content"].to_s })
458
+ end
459
+ r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
460
+ end
461
+ end
462
+
463
+ # Un-specialize: the shared skill stays, and the agent falls back to it.
464
+ r.post "delete" do
465
+ check_csrf!
466
+ with_flash("Specialization removed — the agent falls back to the shared skill.") do
467
+ dispatch(:delete_skill, { name: name, agent: id })
468
+ end
469
+ r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
470
+ end
314
471
  end
315
- r.redirect(agent_path(id))
316
472
  end
317
473
 
318
474
  # Agent memory. Scoped by tenant = agent id — the
319
475
  # SAME tenant the playground uses when chatting, so what is edited
320
- # here is what the BIA reads on the turn. Each agent, its own memory.
476
+ # here is what the agent reads on the turn. Each agent, its own memory.
321
477
  r.on "memory" do
322
478
  r.post "fact" do
323
479
  check_csrf!
@@ -381,25 +537,37 @@ module Studio
381
537
  view("skills")
382
538
  end
383
539
  end
384
- # Enables/disables the skill on N agents at once.
540
+ # Enables/disables the skill on N agents at once, and marks it always-on for
541
+ # the ones that checked `eager`. Both are per-agent decisions about the same
542
+ # skill, so they are the same form and the same dispatch.
385
543
  r.post "agents" do
386
544
  check_csrf!
387
545
  agent_ids = Array(r.params["agent_ids"]).map(&:to_s)
546
+ eager_ids = Array(r.params["eager_ids"]).map(&:to_s)
388
547
  result = with_flash("Skill's agents updated.") do
389
- dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids })
390
- end
391
- skipped = Array(result && result[:skipped_all])
392
- if skipped.any?
393
- flash["notice"] = "#{flash['notice']} — #{skipped.size} agent(s) with 'all' skills were left intact."
548
+ dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids, eager_ids: eager_ids })
394
549
  end
550
+ note_skipped(result)
395
551
  r.redirect("/studio/skills")
396
552
  end
553
+
554
+ # Seeds a per-agent override from the SHARED body and opens it for editing.
555
+ # Seeded and not blank: specializing means "this, but for me" — starting from
556
+ # an empty editor is how the two versions drift apart on day one.
557
+ r.post "specialize" do
558
+ check_csrf!
559
+ agent_id = presence(r.params["agent_id"])
560
+ with_flash("Specialized for #{agent_id}.") do
561
+ dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
562
+ end
563
+ r.redirect("/studio/agents/#{Rack::Utils.escape(agent_id.to_s)}/skills/#{Rack::Utils.escape(name)}")
564
+ end
397
565
  end
398
566
  end
399
567
 
400
568
  # --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
401
569
  r.on "tools" do
402
- # Data-defined tool authoring (Phase 5). Under /tools/def/* — BEFORE the
570
+ # Data-defined tool authoring. Under /tools/def/* — BEFORE the
403
571
  # generic `r.post String` matcher (which is the allow/deny matrix per agent :id).
404
572
  r.on "def" do
405
573
  # /studio/tools/def/new — empty editor.
@@ -474,7 +642,7 @@ module Studio
474
642
  r.on "settings" do
475
643
  r.is do
476
644
  r.get { render_settings }
477
- # General settings (streaming/timeouts/compaction) → :update_settings.
645
+ # General settings (streaming/timeouts) → :update_settings.
478
646
  r.post do
479
647
  check_csrf!
480
648
  with_flash("Settings saved.") do
@@ -484,7 +652,7 @@ module Studio
484
652
  end
485
653
  end
486
654
 
487
- # Platform model defaults (sub-resource, v2 §10): its own form/route so a
655
+ # Platform model defaults (sub-resource, v2): its own form/route so a
488
656
  # general-settings save never clobbers the model layer.
489
657
  r.post "models" do
490
658
  check_csrf!
@@ -494,7 +662,7 @@ module Studio
494
662
  r.redirect("/studio/settings?s=models")
495
663
  end
496
664
 
497
- # Per-model reasoning defaults (§10, the per-model layer): its own form so a
665
+ # Per-model reasoning defaults (the per-model layer): its own form so a
498
666
  # model-defaults save never clobbers the per-model map, and vice-versa.
499
667
  r.post "model-params" do
500
668
  check_csrf!
@@ -504,7 +672,7 @@ module Studio
504
672
  r.redirect("/studio/settings?s=models")
505
673
  end
506
674
 
507
- # Edge limits (item 33 / §12 G7): the platform rate-limit/cost layer.
675
+ # Edge limits: the platform rate-limit/cost layer.
508
676
  # Its own form for the same reason as models — saves never cross-clobber.
509
677
  r.post "edge" do
510
678
  check_csrf!
@@ -514,7 +682,7 @@ module Studio
514
682
  r.redirect("/studio/settings?s=edge")
515
683
  end
516
684
 
517
- # Evals (RFC-0008 / RFC-0013 §3.9): the judge PANEL and how it agrees. Its own
685
+ # Evals: the judge PANEL and how it agrees. Its own
518
686
  # form, like models and edge — a save here must not clobber those.
519
687
  r.post "evals" do
520
688
  check_csrf!
@@ -619,12 +787,93 @@ module Studio
619
787
  # Session's tool-call trace (debug): grouped by turn in the view.
620
788
  @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
621
789
  .group_by { |t| t["turn"] }
790
+ # Context breakdown per turn: chronological entries.
791
+ @context_traces = insika[:context_trace_store]&.for_session(sid) || []
792
+ # the stage history — the session's outcomes folded
793
+ # into the agent's declared stages (unmapped kinds render raw).
794
+ @stage_history = stage_history_for(sid)
622
795
  view("session")
623
796
  end
624
797
  end
625
798
  end
626
799
 
627
- # --- Tasks: list + detail + operator controls (§12 G5) -------
800
+ # --- Customers: the memory drill ----------------
801
+ # Derived from the store, not a registry: the index enumerates memory
802
+ # cells and classifies them by shape (D6). Every mutation dispatches a
803
+ # Command on the bus — the transport's constitutional rule (app.rb:21).
804
+ # The cell scope in the URL is parsed back into the (tenant, customer)
805
+ # pair: a "memory:acme:c-1" URL must edit the SAME cell the turn reads
806
+ # (E1 live) — passing the raw scope as `tenant` would double-prefix it.
807
+ r.on "customers" do
808
+ r.is do
809
+ r.get { render_customers }
810
+ end
811
+
812
+ r.on String do |raw_scope|
813
+ # Roda captures the segment URL-encoded; a customer cell contains
814
+ # colons (memory:acme:c-1), so decode before parsing.
815
+ scope = Rack::Utils.unescape_path(utf8(raw_scope))
816
+ cell = Insika::MemoryStore.parse_cell(scope)
817
+
818
+ r.get { render_customer(scope, cell) }
819
+
820
+ # Add/edit a fact inline (upsert) — the operator's stamp + audit.
821
+ r.post "fact" do
822
+ check_csrf!
823
+ with_flash("Fact saved.") do
824
+ dispatch(:memory_put_fact, {
825
+ tenant: cell[:tenant], customer: cell[:customer],
826
+ key: presence(r.params["key"]), value: r.params["value"].to_s,
827
+ expires_at: presence(r.params["expires_at"]), operator: "studio"
828
+ })
829
+ end
830
+ r.redirect(customer_path(scope))
831
+ end
832
+
833
+ r.post "forget-fact" do
834
+ check_csrf!
835
+ with_flash("Fact forgotten.") do
836
+ dispatch(:memory_forget_fact, {
837
+ tenant: cell[:tenant], customer: cell[:customer],
838
+ key: presence(r.params["key"]), operator: "studio"
839
+ })
840
+ end
841
+ r.redirect(customer_path(scope))
842
+ end
843
+
844
+ # The LGPD access right: JSON download of the cell's content (D7 —
845
+ # the RETURN value is the download; the event stays counts-only). A
846
+ # cell with no customer (a _default cell, reachable by URL) is a
847
+ # ValidationError -> red flash, never a 500.
848
+ r.post "export" do
849
+ check_csrf!
850
+ result = dispatch(:export_customer_memory, {
851
+ tenant: cell[:tenant], customer: cell[:customer],
852
+ operator: "studio"
853
+ })
854
+ response["content-type"] = "application/json"
855
+ response["content-disposition"] =
856
+ %(attachment; filename="memory-#{Rack::Utils.escape_path(scope.tr(':', '-'))}.json")
857
+ JSON.pretty_generate(result)
858
+ rescue Insika::ValidationError => e
859
+ flash["error"] = e.message
860
+ r.redirect(customer_path(scope))
861
+ end
862
+
863
+ # The LGPD forget — purges the cell AND the customer's sessions.
864
+ r.post "forget" do
865
+ check_csrf!
866
+ with_flash("Customer forgotten.") do
867
+ dispatch(:forget_customer, {
868
+ tenant: cell[:tenant], customer: cell[:customer], operator: "studio"
869
+ })
870
+ end
871
+ r.redirect("/studio/customers")
872
+ end
873
+ end
874
+ end
875
+
876
+ # Tasks: list + detail + operator controls -------
628
877
  # Parity with server/admin: READS the task/checkpoint/pending stores to
629
878
  # render; pause/resume/cancel dispatch Commands on the bus (never a direct
630
879
  # store write). Every control audits the ATTEMPT to the EventStream first.
@@ -658,7 +907,7 @@ module Studio
658
907
  end
659
908
  end
660
909
 
661
- # --- Approvals: human-in-the-loop inbox (§12 G5) -------------
910
+ # Approvals: human-in-the-loop inbox -------------
662
911
  # Lists every :pending action across tasks; resolving one dispatches
663
912
  # :approve_action, which resolves the store AND wakes the suspended turn.
664
913
  r.on "approvals" do
@@ -677,7 +926,7 @@ module Studio
677
926
  end
678
927
  end
679
928
 
680
- # --- Evals: the cases that grade an agent (RFC-0008 §3.1) -------
929
+ # Evals: the cases that grade an agent -------
681
930
  # A case is DATA in the same YAML shape the corpus files use, so what an operator
682
931
  # edits here is what a pull request would review. The one loader validates it, on
683
932
  # the way in — a malformed case is a red flash, never a silently skipped test.
@@ -702,8 +951,8 @@ r.on "evals" do
702
951
  end
703
952
 
704
953
  # --- Refinement: what broke in real traffic, and what to do about it --
705
- # `POST /refinement` runs the report (RFC-0013 phase A). `POST
706
- # /refinement/propose` is phase C: the configured model writes a candidate from
954
+ # `POST /refinement` runs the report. `POST
955
+ # refinement/propose` is: the configured model writes a candidate from
707
956
  # the findings and the gate scores it by replaying the golden set. `POST
708
957
  # /refinement/resolve` is a human approving or rejecting what the gate passed.
709
958
  # All three go through the bus like every other Studio write — this page reads
@@ -748,6 +997,156 @@ end
748
997
  end
749
998
  end
750
999
 
1000
+ # --- Harvest: the gated skill loop ----------------------
1001
+ # Reads the HarvestStore directly (the Studio's constitutional split);
1002
+ # every mutation — mine now, gate, promote, reject, rollback — dispatches
1003
+ # a bus command. The gate POST is slow on purpose: it replays the golden
1004
+ # set, like Refinement's propose button.
1005
+ r.on "harvest" do
1006
+ r.is do
1007
+ r.get { render_harvest }
1008
+ r.post do # mine now (manual trigger, D10)
1009
+ check_csrf!
1010
+ agent = presence(r.params["agent"])
1011
+ payload = { agent: agent, full: r.params["full"] == "1" }
1012
+ control_action(:run_harvest, payload, ok: "Harvest run finished.")
1013
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1014
+ end
1015
+ end
1016
+
1017
+ r.post "gate" do # the double gate on one candidate (slow — replay)
1018
+ check_csrf!
1019
+ agent = presence(r.params["agent"])
1020
+ payload = { candidate_id: presence(r.params["candidate_id"]) }
1021
+ control_action(:gate_harvest, payload, ok: "Gated — see the verdicts below.")
1022
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1023
+ end
1024
+
1025
+ r.post "promote" do
1026
+ check_csrf!
1027
+ agent = presence(r.params["agent"])
1028
+ payload = { candidate_id: presence(r.params["candidate_id"]),
1029
+ operator: "studio", note: presence(r.params["note"]) }.compact
1030
+ control_action(:promote_harvest, payload,
1031
+ ok: "Promoted — the skill is live for this store.")
1032
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1033
+ end
1034
+
1035
+ r.post "reject" do
1036
+ check_csrf!
1037
+ agent = presence(r.params["agent"])
1038
+ payload = { candidate_id: presence(r.params["candidate_id"]),
1039
+ operator: "studio", note: presence(r.params["note"]) }.compact
1040
+ control_action(:reject_harvest, payload, ok: "Skill rejected.")
1041
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1042
+ end
1043
+
1044
+ r.post "rollback" do
1045
+ check_csrf!
1046
+ agent = presence(r.params["agent"])
1047
+ payload = { snapshot_ref: presence(r.params["snapshot_ref"]),
1048
+ operator: "studio", reason: presence(r.params["reason"]) }.compact
1049
+ control_action(:rollback_harvest, payload, ok: "Rolled back to the snapshot.")
1050
+ r.redirect("/studio/harvest?agent=#{Rack::Utils.escape(agent.to_s)}")
1051
+ end
1052
+ end
1053
+
1054
+ # --- Facts: the human gate on distilled proposals --------
1055
+ # Reads ProposalStore directly (the Studio's constitutional split — reads
1056
+ # hit the stores, mutations go through the bus); the ONLY mutation — the
1057
+ # approve/reject/dismiss answer — dispatches :resolve_proposal.
1058
+ r.on "facts" do
1059
+ r.get { render_facts }
1060
+ r.post "resolve" do
1061
+ check_csrf!
1062
+ decision = presence(r.params["decision"])
1063
+ ok = { "approved" => "Fact saved to memory.", "rejected" => "Proposal rejected.",
1064
+ "dismissed" => "Proposal dismissed — it will not be proposed again." }[decision]
1065
+ with_flash(ok || "Proposal resolved.") do
1066
+ dispatch(:resolve_proposal, { proposal_id: presence(r.params["proposal_id"]),
1067
+ decision: decision, note: presence(r.params["note"]),
1068
+ operator: "studio" })
1069
+ end
1070
+ filter = presence(r.params["filter"])
1071
+ r.redirect("/studio/facts#{filter ? "?store=#{Rack::Utils.escape(filter)}" : ""}")
1072
+ end
1073
+ end
1074
+
1075
+ # --- Parity: the running shadow verdict -----------------
1076
+ # Reads stores and folds the verdict on demand; the judge button is a slow
1077
+ # synchronous POST, exactly like Refinement's gate, for the same reason: it
1078
+ # returns when the answer is real. There is deliberately no "freeze the
1079
+ # criterion" button — freezing is a git commit, reviewed by a human.
1080
+ r.on "parity" do
1081
+ r.is do
1082
+ r.get { render_parity }
1083
+ r.post do
1084
+ check_csrf!
1085
+ agent = presence(r.params["agent"])
1086
+ limit = presence(r.params["limit"]).to_i
1087
+ payload = { agent: agent }.compact
1088
+ payload[:limit] = limit if limit.positive?
1089
+ control_action(:judge_shadow_pairs, payload,
1090
+ ok: "Judged — the verdict below is recomputed.")
1091
+ r.redirect("/studio/parity?agent=#{Rack::Utils.escape(agent.to_s)}")
1092
+ end
1093
+ end
1094
+ end
1095
+
1096
+ # --- Funnel: the outcome funnel per store ----------------
1097
+ # Reads the fold's cells + the BudgetLedger's current counters directly
1098
+ # (D7 — the scorecard's constitutional split); the ONE mutation — the
1099
+ # baseline freeze — dispatches :freeze_funnel_baseline on the bus.
1100
+ r.on "funnel" do
1101
+ r.get { render_funnel }
1102
+ r.on String do |agent_id|
1103
+ agent_id = utf8(agent_id)
1104
+ r.post "freeze" do
1105
+ check_csrf!
1106
+ with_flash("Baseline frozen.") do
1107
+ dispatch(:freeze_funnel_baseline,
1108
+ { agent: agent_id,
1109
+ from: presence(r.params["from"]),
1110
+ to: presence(r.params["to"]),
1111
+ # the card's hidden tenant field: WITHOUT it, a
1112
+ # multi-tenant deployment freezes the "platform" pair
1113
+ # while the operator believes they froze the store's.
1114
+ tenant: presence(r.params["tenant"]),
1115
+ operator: "studio" })
1116
+ end
1117
+ r.redirect("/studio/funnel")
1118
+ end
1119
+ end
1120
+ end
1121
+
1122
+ # --- Follow-ups: the schedule records + the A/B readout --
1123
+ # Reads FollowupStore/ContactStore/OutcomeStore directly (D10 — the
1124
+ # Studio's constitutional split); the ONLY mutations — cancel a pending
1125
+ # record, force-revoke a contact (an opt-out event that never arrived) —
1126
+ # dispatch bus commands.
1127
+ r.on "followups" do
1128
+ r.get { render_followups }
1129
+ r.on String do |agent_id|
1130
+ agent_id = utf8(agent_id)
1131
+ r.post "cancel" do
1132
+ check_csrf!
1133
+ with_flash("Follow-up cancelled.") do
1134
+ dispatch(:cancel_followup, { followup_id: r.params["id"] })
1135
+ end
1136
+ r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
1137
+ end
1138
+ r.post "revoke" do
1139
+ check_csrf!
1140
+ with_flash("Contact revoked — every pending follow-up cancelled.") do
1141
+ dispatch(:revoke_contact,
1142
+ { customer: r.params["customer"], operator: "studio" },
1143
+ tenant: presence(r.params["tenant"]))
1144
+ end
1145
+ r.redirect("/studio/followups?agent=#{Rack::Utils.escape(agent_id)}")
1146
+ end
1147
+ end
1148
+ end
1149
+
751
1150
  # Playground: sends `send_message` (the SAME Command as the API) and streams the
752
1151
  # response live through the `live-transcript` island (SSE from /studio/events).
753
1152
  r.on "playground" do
@@ -755,7 +1154,15 @@ end
755
1154
  @agent = presence(r.params["agent"]) || default_agent
756
1155
  @session_id = presence(r.params["session_id"])
757
1156
  @agents = insika[:profile_source].ids.sort
758
- # Server-side echo + continuity (§11 A1): render the session's persisted
1157
+ # Session combobox: recent conversations as <datalist> suggestions for the
1158
+ # session field. The open session always appears, even if it fell off the
1159
+ # recent list (it is the one being edited).
1160
+ @recent = recent_sessions(limit: 8)
1161
+ if @session_id && @recent.none? { |s| s.id == @session_id }
1162
+ current = insika[:session_store]&.find(@session_id)
1163
+ @recent.unshift(current) if current
1164
+ end
1165
+ # Server-side echo + continuity: render the session's persisted
759
1166
  # transcript as bubbles. The user's message is only persisted at the END
760
1167
  # of the turn, so the just-sent message rides a one-shot flash bubble
761
1168
  # (@sent_message) until it lands in history — an optimistic JS echo can't
@@ -772,7 +1179,7 @@ end
772
1179
  # Blank session = new conversation: created via Command (create_session
773
1180
  # generates the id — the Studio doesn't write to the store directly). A typed id
774
1181
  # continues an existing conversation (send_message requires it to exist).
775
- # The per-chat model pin (v2, §10) is set at creation and rides the whole
1182
+ # The per-chat model pin is set at creation and rides the whole
776
1183
  # conversation, so it only applies to a NEW session — an existing one keeps
777
1184
  # whatever it was pinned to.
778
1185
  session_id = typed_session ||
@@ -780,7 +1187,7 @@ end
780
1187
  provider: presence(r.params["provider"]),
781
1188
  thinking: presence(r.params["thinking"]))
782
1189
  dispatch_send_message(agent: agent, session_id: session_id, message: message)
783
- # Optimistic echo of the just-sent message (§11 A1): survives the redirect
1190
+ # Optimistic echo of the just-sent message: survives the redirect
784
1191
  # as a one-shot flash, rendered as a user bubble on the next GET.
785
1192
  flash["sent_message"] = message unless message.empty?
786
1193
  r.redirect(playground_path(agent, session_id))
@@ -821,16 +1228,28 @@ end
821
1228
  ]],
822
1229
  ["operate", [
823
1230
  ["Chats", "/studio/chats", :chats],
1231
+ ["Customers", "/studio/customers", :chats],
824
1232
  ["Playground", "/studio/playground", :playground],
1233
+ ["Funnel", "/studio/funnel", :funnel],
1234
+ # the Follow-ups page — scheduled, fired, blocked and
1235
+ # cancelled records per agent + the per-arm A/B readout card.
1236
+ ["Follow-ups", "/studio/followups", :followups],
1237
+ # the Facts (wiki) page — the human gate on distilled
1238
+ # proposals (approve/reject/dismiss, the latch, the CAS re-present).
1239
+ ["Facts", "/studio/facts", :facts],
1240
+ # the Harvest page — the human gate on mined skills
1241
+ # (gate, promote, reject, rollback; the append-only promotion log).
1242
+ ["Harvest", "/studio/harvest", :harvest],
825
1243
  ["Tasks", "/studio/tasks", :tasks],
826
1244
  ["Approvals", "/studio/approvals", :approvals],
827
1245
  ["Refinement", "/studio/refinement", :refinement],
828
1246
  ["Evals", "/studio/evals", :evals]
829
- ]]
1247
+ ] + parity_nav_item
1248
+ ]
830
1249
  ]
831
1250
  end
832
1251
 
833
- # NAV_ICONS + nav_icon moved to Studio::NavIcons (§11 B6).
1252
+ # NAV_ICONS + nav_icon moved to Studio::NavIcons.
834
1253
 
835
1254
  def authenticated? = session["auth"] == true
836
1255
 
@@ -907,14 +1326,14 @@ end
907
1326
 
908
1327
  def default_agent
909
1328
  ids = insika[:profile_source].ids
910
- ids.include?("bia") ? "bia" : (ids.first || "bia")
1329
+ ids.include?("demo") ? "demo" : (ids.first || "demo")
911
1330
  end
912
1331
 
913
1332
  # --- Transcript display helpers (playground + session viewer) ------------
914
1333
 
915
1334
  # A stored message's content for display: strings as-is; structured payloads
916
1335
  # as pretty JSON. NEVER Ruby #inspect (session.erb:45 leaked hashrockets to the
917
- # operator). §11 A2.
1336
+ # operator)..
918
1337
  def message_content(content)
919
1338
  return content.to_s if content.is_a?(String)
920
1339
 
@@ -923,6 +1342,29 @@ end
923
1342
  content.to_s
924
1343
  end
925
1344
 
1345
+ # An activation label from the context trace, as the operator reads it:
1346
+ # "gift-concierge · trigger:presente". The REASON is the point of the card — a
1347
+ # bare list of names answers "was something injected", never "which one did I
1348
+ # trigger". A label with no reason (a plugin-supplied body) shows just the name.
1349
+ def skill_label(label)
1350
+ return label.to_s unless label.is_a?(Hash)
1351
+
1352
+ name = label["name"].to_s
1353
+ reason = label["reason"].to_s
1354
+ reason.empty? ? name : "#{name} · #{reason}"
1355
+ end
1356
+
1357
+ # One word for the whole turn, for the collapsed summary: the shared reason when
1358
+ # every body arrived the same way, "mixed" when they did not. A turn CAN mix (the
1359
+ # agent's eager set plus what this message triggered), which is exactly why the
1360
+ # single per-turn `mode` this replaced was a lie waiting to happen.
1361
+ def skill_mode(labels)
1362
+ kinds = Array(labels).map { |l| (l.is_a?(Hash) ? l["reason"].to_s : "").split(":").first }.uniq
1363
+ return "context" if kinds.empty? || kinds.any?(&:nil?) || kinds.include?("")
1364
+
1365
+ kinds.length == 1 ? kinds.first : "mixed"
1366
+ end
1367
+
926
1368
  # ISO8601 → "HH:MM" for a transcript timestamp; "" when unparseable/absent.
927
1369
  def short_time(iso)
928
1370
  Time.parse(iso.to_s).strftime("%H:%M")
@@ -971,6 +1413,7 @@ end
971
1413
 
972
1414
  def render_agent_detail
973
1415
  id = @agent.id
1416
+ @config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
974
1417
  store = insika[:agent_file_store]
975
1418
  @prompt_files = Array(@agent.prompt_files).map do |name|
976
1419
  {
@@ -981,16 +1424,24 @@ end
981
1424
  end
982
1425
  @all_skills = insika[:skill_catalog]&.all || []
983
1426
  @agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
984
- # v2 (§10) config surfaces: generation params + model fence. AgentProfile.build
1427
+ # v2 config surfaces: generation params + model fence. AgentProfile.build
985
1428
  # string-keys these hashes, so the form helpers read plain string keys.
986
1429
  @params = @agent.params
987
1430
  @model_policy_allow = model_policy_allow(@agent)
988
- # guardrails config (RFC-0009); nil when the agent never configured any.
1431
+ # guardrails config; nil when the agent never configured any.
989
1432
  @guardrails = @agent.guardrails || {}
990
1433
  mem = insika[:memory_store]
991
1434
  @facts = mem ? mem.facts(tenant: id) : []
992
1435
  @notes = mem ? mem.notes(tenant: id, limit: 20) : []
993
1436
  @recent_sessions = recent_sessions
1437
+ # WS7: last outcome + per-day series for THIS agent. The grid already
1438
+ # shows the last-outcome pill; the series is the period view.
1439
+ outcomes = insika[:outcome_store]
1440
+ @latest_outcome = outcomes&.latest_per_agent&.[](id)
1441
+ @outcome_series = outcomes ? outcomes.series(agent: id) : {}
1442
+ # the per-agent cache-hit series (nil store -> the view's
1443
+ # empty state; same guard as the tool trace).
1444
+ @cache_series = insika[:cache_series_store]&.for_agent(id) || []
994
1445
  view("agent_detail")
995
1446
  end
996
1447
 
@@ -1002,8 +1453,95 @@ end
1002
1453
  params[key.to_s].nil? ? "" : params[key.to_s]
1003
1454
  end
1004
1455
 
1456
+ # A free-form config block (string-keyed by AgentProfile.build) rendered
1457
+ # as the JSON textarea the form parses back. Pretty-printed so an operator
1458
+ # actually reads the current state; "" when the block is absent.
1459
+ def agent_json(config)
1460
+ return "" if config.nil? || config.empty?
1461
+
1462
+ JSON.pretty_generate(config)
1463
+ rescue StandardError
1464
+ config.to_s
1465
+ end
1466
+
1467
+ # A list field as comma-joined text for the form's textarea.
1468
+ def agent_list(list)
1469
+ Array(list).join(", ")
1470
+ end
1471
+
1472
+ # A list field as newline-joined lines for the form's textarea.
1473
+ def agent_lines(list)
1474
+ Array(list).join("\n")
1475
+ end
1476
+
1477
+ # The shared "filter by agent" GET form used by every shared screen
1478
+ # (home, chats, tasks, approvals, customers, evals, funnel, follow-ups,
1479
+ # facts, parity). Same markup everywhere: a select that submits on change.
1480
+ def agent_filter_form(path, current)
1481
+ ids = insika[:profile_source].ids.sort
1482
+ options = [["", "all"]] + ids.map { |id| [id, id] }
1483
+ rows = options.map do |value, label|
1484
+ %(<option value="#{value}"#{' selected' if value.to_s == current.to_s}>#{label}</option>)
1485
+ end.join
1486
+ %(<form method="get" action="#{path}" class="actions inline"><label>Agent <select name="agent" onchange="this.form.submit()">#{rows}</select></label></form>)
1487
+ end
1488
+
1489
+ # The sessions of one agent — the session stamps its agent in
1490
+ # `vars["agent"]`, so any list of sessions is filterable by agent.
1491
+ def agent_sessions(sessions, agent)
1492
+ return sessions if agent.nil? || agent.empty?
1493
+
1494
+ sessions.select { |s| session_agent(s) == agent }
1495
+ end
1496
+
1497
+ def session_agent(session)
1498
+ vars = session.respond_to?(:vars) ? session.vars : nil
1499
+ vars.is_a?(Hash) ? vars["agent"].to_s : ""
1500
+ end
1501
+
1502
+ # The distill button's scope (the DistillEngine's own scan, scoped to ONE
1503
+ # agent): the agent's sessions that are idle past the pack's window, long
1504
+ # enough, and not yet distilled. Oldest first, capped — each entry is a
1505
+ # provider call, so one click stays bounded. Reads hit the stores; the
1506
+ # writes go through :run_distillation on the bus.
1507
+ def due_distill_sessions(agent_id, limit: 5)
1508
+ store = insika[:session_store]
1509
+ proposals = insika[:proposal_store]
1510
+ profile = insika[:profile_source].fetch(agent_id)
1511
+ return [] if profile.nil? || store.nil? || proposals.nil?
1512
+
1513
+ config = Insika::Coercion.deep_stringify(profile.distill) || {}
1514
+ idle_hours = positive_int(config["idle_hours"]) || 6
1515
+ min_messages = positive_int(config["min_messages"]) || 3
1516
+ cutoff = Time.now.utc - idle_hours * 3600
1517
+ store.each_id.filter_map do |sid|
1518
+ session = store.find(sid)
1519
+ next unless session
1520
+ next unless session.vars.is_a?(Hash) && session.vars["agent"] == agent_id
1521
+ next if Insika::Coercion.blank?(session.vars["customer"])
1522
+ next unless aged?(session.updated_at, cutoff)
1523
+ next if session.messages.size < min_messages
1524
+ next if proposals.distilled?(sid)
1525
+
1526
+ [sid, session.updated_at.to_s]
1527
+ end.sort_by(&:last).map(&:first).first(limit)
1528
+ end
1529
+
1530
+ def positive_int(value)
1531
+ v = value.to_i
1532
+ v.positive? ? v : nil
1533
+ end
1534
+
1535
+ def aged?(updated_at, cutoff)
1536
+ return false if Insika::Coercion.blank?(updated_at)
1537
+
1538
+ Time.iso8601(updated_at.to_s) <= cutoff
1539
+ rescue ArgumentError
1540
+ false
1541
+ end
1542
+
1005
1543
  # Renders the reasoning <select> shared by the agent config, settings and
1006
- # playground (§10, 4-layer). `blank_label` names the empty option — the
1544
+ # playground (4-layer). `blank_label` names the empty option — the
1007
1545
  # "inherit the broader layer" / provider-default choice. Values come from a
1008
1546
  # fixed constant (safe to emit); `current` is only compared, never output.
1009
1547
  def thinking_select(name, current, blank_label)
@@ -1047,21 +1585,34 @@ end
1047
1585
 
1048
1586
  # Config patch from the form (native types: memory bool, limits int).
1049
1587
  # Preserves the existing limits, overwriting only the form's fields.
1050
- # `model` is OPTIONAL as of v2 (§10): blank clears it, so the agent inherits
1588
+ # `model` is OPTIONAL as of v2: blank clears it, so the agent inherits
1051
1589
  # the platform `default_model` via the ModelResolver — the config form is the
1052
1590
  # place that surfaces that layering. params/model_policy: see the helpers below.
1053
1591
  # config_patch/guardrails_patch/guardrail_responses_patch/params_patch/
1054
- # model_policy_patch/coerce moved to Studio::Forms (§11 B6).
1592
+ # model_policy_patch/coerce moved to Studio::Forms.
1055
1593
 
1056
1594
  # --- Skills index --------------------------------------------------------
1057
1595
 
1058
1596
  # Master data for the Skills drill-down (list + authored badges + agents),
1059
1597
  # shared by every skill route so the master pane always renders. @selected
1060
1598
  # drives the detail pane (nil = none, "" = new, name = edit).
1061
- def load_skills_master
1062
- @skills = (insika[:skill_catalog]&.all || []).sort_by(&:name)
1063
- @stored = insika[:skill_store] ? insika[:skill_store].names : []
1599
+ # `agent` renders the AGENT's view of the catalog: its own version of each shared
1600
+ # skill, plus whatever is private to it. Absent = the shared catalog.
1601
+ def load_skills_master(agent: nil)
1602
+ catalog = insika[:skill_catalog]
1603
+ @skills = (catalog ? catalog.all(agent: agent) : []).sort_by(&:name)
1604
+ @stored = insika[:skill_store] ? insika[:skill_store].names(agent: agent) : []
1064
1605
  @agents = insika[:profile_source].all.sort_by(&:id)
1606
+ # Which agents specialized THIS skill — the availability grid shows it, so an
1607
+ # override is discoverable from the shared skill it overrides.
1608
+ @specialized = insika[:skill_store] ? specialized_by : {}
1609
+ end
1610
+
1611
+ # { skill name => [agent ids] } across every agent scope in the store.
1612
+ def specialized_by
1613
+ insika[:skill_store].agents.each_with_object({}) do |agent, acc|
1614
+ insika[:skill_store].names(agent: agent).each { |name| (acc[name] ||= []) << agent }
1615
+ end
1065
1616
  end
1066
1617
 
1067
1618
  def render_skills_index
@@ -1076,14 +1627,20 @@ end
1076
1627
  # Raw content for the editor: prefer the store (real SKILL.md), otherwise
1077
1628
  # reconstruct from what the catalog parsed (editing a disk skill
1078
1629
  # creates an override in the store — Store wins).
1079
- def skill_source(name)
1080
- raw = insika[:skill_store]&.get(name)
1630
+ def skill_source(name, agent: nil)
1631
+ raw = insika[:skill_store]&.get(name, agent: agent)
1081
1632
  return raw if raw
1082
1633
 
1083
- skill = insika[:skill_catalog]&.find(name)
1634
+ skill = insika[:skill_catalog]&.find(name, agent: agent)
1084
1635
  return new_skill_template(name) unless skill
1085
1636
 
1086
- "---\nname: #{skill.name}\ndescription: #{skill.description}\n---\n\n#{skill.body}\n"
1637
+ # The WHOLE frontmatter, not just name/description: this text seeds the
1638
+ # specialize action and any first edit of a disk skill, and an override that
1639
+ # silently drops `triggers:` turns the agent's deterministic activation off.
1640
+ fm = ["name: #{skill.name}", "description: #{skill.description}"]
1641
+ fm << "triggers: #{skill.triggers.join(', ')}" if Array(skill.triggers).any?
1642
+ fm << "companions: #{skill.companions.join(', ')}" if Array(skill.companions).any?
1643
+ "---\n#{fm.join("\n")}\n---\n\n#{skill.body}\n"
1087
1644
  end
1088
1645
 
1089
1646
  def new_skill_template(name = "my-skill")
@@ -1097,6 +1654,31 @@ end
1097
1654
  profile.skills.nil? || Array(profile.skills).map(&:to_s).include?(skill_name.to_s)
1098
1655
  end
1099
1656
 
1657
+ # Is this skill always-on for this agent? `skills_eager` is nil/false (none), true
1658
+ # (blanket) or a list — NOT allowlist semantics, so nil must read as false here.
1659
+ def skill_eager_for?(profile, skill_name)
1660
+ spec = profile.skills_eager
1661
+ return true if Insika::Coercion.truthy?(spec)
1662
+ return false if spec.nil? || spec == false
1663
+
1664
+ Array(spec).map(&:to_s).include?(skill_name.to_s)
1665
+ end
1666
+
1667
+ # The two "left intact" outcomes of :set_skill_agents: an agent whose allowlist (or
1668
+ # eager set) is "all" cannot have ONE name removed from it without materializing an
1669
+ # explicit list, which would be a destructive surprise. Say so instead of silently
1670
+ # doing nothing.
1671
+ def note_skipped(result)
1672
+ return unless result
1673
+
1674
+ parts = []
1675
+ skipped = Array(result[:skipped_all])
1676
+ eager = Array(result[:skipped_eager_all])
1677
+ parts << "#{skipped.size} agent(s) with 'all' skills were left intact" if skipped.any?
1678
+ parts << "#{eager.size} agent(s) with blanket eager were left intact" if eager.any?
1679
+ flash["notice"] = "#{flash['notice']} — #{parts.join(', ')}." if parts.any?
1680
+ end
1681
+
1100
1682
  # --- Tools matrix --------------------------------------------------------
1101
1683
 
1102
1684
  def render_tools_matrix
@@ -1137,7 +1719,7 @@ end
1137
1719
  tools.count { |t| allow.include?(t.name.to_s) && !tool_denied_for?(profile, t.name) }
1138
1720
  end
1139
1721
 
1140
- # --- Data-defined tool authoring (Phase 5) -------------------------------
1722
+ # Data-defined tool authoring -------------------------------
1141
1723
 
1142
1724
  def render_tool_edit(name:, tool:)
1143
1725
  @tool_name = name
@@ -1164,7 +1746,7 @@ end
1164
1746
  }
1165
1747
  end
1166
1748
 
1167
- # tool_patch/parse_parameters moved to Studio::Forms (§11 B6).
1749
+ # tool_patch/parse_parameters moved to Studio::Forms.
1168
1750
 
1169
1751
  # Inverse of parse_parameters: the stored params -> text for the textarea. A schema
1170
1752
  # the flat sugar can express round-trips as pipe lines (the friendly form); anything
@@ -1242,9 +1824,12 @@ end
1242
1824
  # At-a-glance dashboard: counts + activity + recent conversations. All from
1243
1825
  # data the Studio already reads (one scan of the session store); no new
1244
1826
  # metrics pipeline. `active_now` = sessions touched in the last 5 minutes.
1827
+ # `?agent=` narrows every number to one agent (the sessions stamp their
1828
+ # author in `vars["agent"]`).
1245
1829
  def render_home
1246
1830
  ps = insika[:profile_source]
1247
- sessions = all_sessions
1831
+ @agent = presence(request.params["agent"])
1832
+ sessions = agent_sessions(all_sessions, @agent)
1248
1833
  @counts = {
1249
1834
  "conversations" => sessions.size,
1250
1835
  "messages" => sessions.sum { |s| Array(s.messages).size },
@@ -1324,6 +1909,10 @@ end
1324
1909
 
1325
1910
  # --- Settings + LLM providers ----------------------------------
1326
1911
 
1912
+ # The agent config tab's groups, in sidebar order. The view renders the
1913
+ # same keys; a bogus ?cfg= falls back to the first group.
1914
+ CONFIG_SECTIONS = %w[model guardrails grounding funnel followups distill harvest refinement budget_rel routing advanced].freeze
1915
+
1327
1916
  SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
1328
1917
  def render_settings
1329
1918
  store = insika[:settings_store]
@@ -1333,10 +1922,10 @@ end
1333
1922
  view("settings")
1334
1923
  end
1335
1924
 
1336
- # Settings patch from the form. streaming/compaction are bool
1337
- # (checkbox); the timeouts are integers; compaction.keep_last integer. Only what
1338
- # came in the form enters the patch (the rest and the defaults are preserved in the store).
1339
- # settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms (§11 B6).
1925
+ # Settings patch from the form. streaming is a bool (checkbox); the timeouts
1926
+ # are integers. Only what came in the form enters the patch (the rest and
1927
+ # the defaults are preserved in the store).
1928
+ # settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms.
1340
1929
 
1341
1930
  # --- MCP -------------------------------------------------------
1342
1931
 
@@ -1345,7 +1934,7 @@ end
1345
1934
  view("mcp")
1346
1935
  end
1347
1936
 
1348
- # mcp_patch moved to Studio::Forms (§11 B6).
1937
+ # mcp_patch moved to Studio::Forms.
1349
1938
 
1350
1939
  # --- System-files ----------------------------------------------
1351
1940
 
@@ -1361,20 +1950,67 @@ end
1361
1950
  # --- Chats -----------------------------------------------------
1362
1951
 
1363
1952
  def render_chats
1364
- @sessions = recent_sessions(limit: 100)
1953
+ @agent = presence(request.params["agent"])
1954
+ @sessions = agent_sessions(recent_sessions(limit: 100), @agent)
1365
1955
  view("chats")
1366
1956
  end
1367
1957
 
1368
- # --- Tasks & Approvals (§12 G5) --------------------------------
1958
+ # --- Customers ----------------------------------
1959
+
1960
+ # The index: every CUSTOMER memory cell, grouped by tenant. The caller
1961
+ # passes `reserved:` (profile ids + _default) so the agent-memory tab's
1962
+ # cells and the shared cell stay out of the drill (D6 — the store is
1963
+ # policy-free). Fact count per cell via a keys-only list (no payload
1964
+ # reads per conversation on every render).
1965
+ def render_customers
1966
+ mem = insika[:memory_store]
1967
+ agent_ids = insika[:profile_source] ? insika[:profile_source].all.map(&:id) : []
1968
+ @agent = presence(request.params["agent"])
1969
+ @rows = mem ? mem.customer_cells(reserved: Array(agent_ids) + ["_default"]).map do |cell|
1970
+ { cell: cell, count: mem.fact_count(tenant: cell[:tenant], customer: cell[:customer]) }
1971
+ end : []
1972
+ # ?agent= narrows to one tenant — the memory scope is the agent (the
1973
+ # agent-memory tab writes under tenant = agent id).
1974
+ @rows = @rows.select { |row| row[:cell][:tenant] == @agent } if @agent
1975
+ @by_tenant = @rows.group_by { |row| row[:cell][:tenant] }
1976
+ view("customers")
1977
+ end
1978
+
1979
+ # The detail: the parsed cell, its facts (expired excluded by C1), notes
1980
+ # (append-only), and the operator audit (digests + counts, never content).
1981
+ def render_customer(scope, cell)
1982
+ @cell = cell
1983
+ mem = insika[:memory_store]
1984
+ @facts = mem ? mem.facts(tenant: cell[:tenant], customer: cell[:customer]) : []
1985
+ @notes = mem ? mem.notes(tenant: cell[:tenant], customer: cell[:customer], limit: 50) : []
1986
+ @audit = insika[:memory_audit_store]&.for_cell(scope) || []
1987
+ view("customer")
1988
+ end
1989
+
1990
+ def customer_path(scope)
1991
+ # escape_path, not escape: the route decodes with unescape_path, which
1992
+ # does not turn '+' back into a space (escape's form-encoding would).
1993
+ "/studio/customers/#{Rack::Utils.escape_path(scope)}"
1994
+ end
1995
+
1996
+ # Tasks & Approvals --------------------------------
1369
1997
 
1370
1998
  # Task list, most-recently-updated first. Empty-state if no store was injected.
1999
+ # `?agent=` narrows to one agent (the task's command payload stamps it).
1371
2000
  def render_tasks
1372
2001
  store = insika[:task_store]
2002
+ @agent = presence(request.params["agent"])
1373
2003
  @tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
2004
+ @tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
1374
2005
  @tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
1375
2006
  view("tasks")
1376
2007
  end
1377
2008
 
2009
+ def task_agent(task)
2010
+ command = task.command
2011
+ command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
2012
+ end
2013
+
1378
2014
  # Task detail: @task is set by the route. Adds the open approvals for this task
1379
2015
  # and its latest checkpoint (both degrade to empty when the store is absent).
1380
2016
  def render_task_detail(id)
@@ -1384,15 +2020,20 @@ end
1384
2020
  end
1385
2021
 
1386
2022
  # Approvals inbox: every :pending action across tasks, each paired with its
1387
- # task (for the status pill + a link into the task detail).
2023
+ # task (for the status pill + a link into the task detail). `?agent=`
2024
+ # narrows to one agent (via the owning task).
1388
2025
  def render_approvals
1389
2026
  pstore = insika[:pending_action_store]
1390
2027
  tstore = insika[:task_store]
2028
+ @agent = presence(request.params["agent"])
1391
2029
  @approvals = pstore ? pstore.all_open.map { |pa| { pending: pa, task: tstore&.find(pa.task_id) } } : []
2030
+ if @agent
2031
+ @approvals = @approvals.select { |a| task_agent(a[:task]) == @agent if a[:task] }
2032
+ end
1392
2033
  view("approvals")
1393
2034
  end
1394
2035
 
1395
- # --- Evals (RFC-0008 §3.1) -------------------------------------
2036
+ # Evals -------------------------------------
1396
2037
 
1397
2038
  # The stored cases, grouped by agent, plus the one being edited (?id=). Cases whose
1398
2039
  # stored mapping no longer validates are listed separately: a broken case must be
@@ -1401,6 +2042,8 @@ def render_evals
1401
2042
  store = insika[:golden_store]
1402
2043
  @cases = store ? store.all : []
1403
2044
  @invalid = store ? store.invalid : []
2045
+ @filter = presence(request.params["agent"])
2046
+ @cases = @cases.select { |g| g.agent == @filter } if @filter
1404
2047
  @by_agent = @cases.group_by(&:agent).sort.to_h
1405
2048
  wanted = presence(request.params["id"])
1406
2049
  @case = wanted && @cases.find { |g| g.id == wanted }
@@ -1417,7 +2060,7 @@ def golden_yaml(golden)
1417
2060
  YAML.dump(h.merge("expect" => golden.expect))
1418
2061
  end
1419
2062
 
1420
- # --- Refinement (RFC-0013 phase A) -----------------------------
2063
+ # Refinement -----------------------------
1421
2064
 
1422
2065
  # The agent's latest failure report + its run history. Empty-state when no store
1423
2066
  # was injected or the agent has never been run — the page is the invitation to
@@ -1434,6 +2077,331 @@ end
1434
2077
  view("refinement")
1435
2078
  end
1436
2079
 
2080
+ # --- Parity -------------------------------------------------
2081
+
2082
+ # The nav row exists ONLY when a shadow channel is registered — the page is
2083
+ # not an invitation, unlike Refinement's.
2084
+ def parity_nav_item
2085
+ shadow_channel? ? [["Parity", "/studio/parity", :parity]] : []
2086
+ end
2087
+
2088
+ def shadow_channel?
2089
+ registry = insika[:channel_registry]
2090
+ return false unless registry
2091
+
2092
+ Array(registry.names).any? do |name|
2093
+ channel = registry.find(name)
2094
+ channel.respond_to?(:shadow?) && channel.shadow?
2095
+ end
2096
+ end
2097
+
2098
+ def parity_pairs
2099
+ insika[:shadow_pair_store] ? insika[:shadow_pair_store].each.to_a : []
2100
+ end
2101
+
2102
+ def render_parity
2103
+ @criterion = insika[:parity_criterion]
2104
+ @agent = presence(request.params["agent"])
2105
+ # ONE materialization per request: the fold and the pair list share this
2106
+ # array (the store's counts would scan the whole table a second time).
2107
+ pairs = parity_pairs
2108
+ pairs = pairs.select { |p| p.agent.to_s == @agent } if @agent
2109
+ @report = @criterion && Insika::Parity::Verdict.fold(pairs: pairs, criterion: @criterion)
2110
+ @pairs = pairs.sort_by { |p| p.created_at.to_s }.reverse.first(50)
2111
+ @pending = pairs.count { |p| p.status == :complete }
2112
+ if @criterion
2113
+ @judge_cost = @pending * @criterion.rule.min_judge_models * 2
2114
+ @judge_models = ((insika[:settings_store]&.get || {})["evals"] || {})
2115
+ end
2116
+ # Judge agreement : split/unknown from the fold, plus how many
2117
+ # verdicts flipped with presentation order.
2118
+ @agreement = { order_dependent: pairs.count { |p| p.verdict.is_a?(Hash) && p.verdict["order_dependent"] } }
2119
+ view("parity")
2120
+ end
2121
+
2122
+ # --- Funnel ------------------------------------------------
2123
+
2124
+ # One card per STORE (agent) that declares a funnel, each with a block per
2125
+ # tenant that has folded cells. The only mutation on this page — the
2126
+ # baseline freeze — dispatches :freeze_funnel_baseline (D7).
2127
+ def render_funnel
2128
+ profiles = insika[:profile_source]&.all || []
2129
+ p = request.params["period"].to_i
2130
+ @period = p.positive? ? p : 30
2131
+ @agent = presence(request.params["agent"])
2132
+ profiles = profiles.select { |p| p.id == @agent } if @agent
2133
+ @stores = profiles.filter_map do |p|
2134
+ decl = Insika::FunnelDeclaration.parse(p.funnel)
2135
+ next unless decl
2136
+
2137
+ { profile: p, decl: decl, tenants: tenants_for(p.id) }
2138
+ end
2139
+ view("funnel")
2140
+ end
2141
+
2142
+ # the Follow-ups page. Per agent WITH a parsed policy, one
2143
+ # block: the pending/fired/cancelled/blocked lists (blocked rows carry the
2144
+ # reason pill), the per-arm counts, the read-only policy summary and the
2145
+ # A/B card (D10 — reads stores, mutates through bus commands only).
2146
+ def render_followups
2147
+ profiles = insika[:profile_source]&.all || []
2148
+ @agent = presence(request.params["agent"])
2149
+ profiles = profiles.select { |p| p.id == @agent } if @agent
2150
+ @stores = profiles.filter_map do |p|
2151
+ decl = Insika::FollowupPolicy.parse(p.followup)
2152
+ next unless decl
2153
+
2154
+ {
2155
+ profile: p, policy: decl,
2156
+ tenant: presence(request.params["tenant"]) || "platform",
2157
+ records: followup_records(p.id, decl),
2158
+ ab: followup_ab(p.id, decl)
2159
+ }
2160
+ end
2161
+ @has_followups = !@stores.empty?
2162
+ view("followups")
2163
+ end
2164
+
2165
+ # the Facts (wiki) page — the human gate on distilled
2166
+ # proposals. Reads ProposalStore + SessionStore directly (D7 — evidence is
2167
+ # a link, never a copy: the excerpt is read from the session at request
2168
+ # time); the mutations dispatch :resolve_proposal on the bus.
2169
+ def render_facts
2170
+ store = insika[:proposal_store]
2171
+ @pending = store ? store.pending(limit: 100) : []
2172
+ @stale = store ? store.stale(limit: 50) : []
2173
+ @recent = store ? store.resolved(limit: 20) : []
2174
+ # `?agent=` filters by the proposal's AGENT (the origin session's
2175
+ # stamp); `?store=` keeps filtering by the memory-cell scope (the
2176
+ # pre-review behavior). Both narrow the same three lists.
2177
+ @filter = presence(request.params["agent"]) || presence(request.params["store"])
2178
+ if @filter
2179
+ @pending = @pending.select { |p| scope_matches?(p, @filter) }
2180
+ @stale = @stale.select { |p| scope_matches?(p, @filter) }
2181
+ @recent = @recent.select { |p| scope_matches?(p, @filter) }
2182
+ end
2183
+ # The select: the filtered agents (profile ids) PLUS every scope a
2184
+ # proposal ever carried, so an agent with proposals and one without can
2185
+ # both be picked.
2186
+ @stores = (insika[:profile_source].ids.sort +
2187
+ (@pending + @stale + @recent).map(&:scope)).uniq.sort
2188
+ @sessions = insika[:session_store]
2189
+ view("facts")
2190
+ end
2191
+
2192
+ # A proposal matches the filter as the memory scope it carries, or as the
2193
+ # agent of the session it came from (when the session is still around).
2194
+ def scope_matches?(proposal, filter)
2195
+ return true if proposal.scope == filter
2196
+
2197
+ session = insika[:session_store]&.find(proposal.session_ref)
2198
+ session && session_agent(session) == filter
2199
+ end
2200
+
2201
+ # the Harvest page — the human gate on mined skills. Reads
2202
+ # HarvestStore/SessionStore directly (the evidence excerpt is read from the
2203
+ # session at request time — D7: ids and indexes only are stored); every
2204
+ # mutation dispatches a bus command.
2205
+ def render_harvest
2206
+ @agents = insika[:profile_source].ids.sort
2207
+ @agent = presence(request.params["agent"]) || @agents.first
2208
+ store = insika[:harvest_store]
2209
+ # EVERY list respects the agent filter (the review fix) — the page is a
2210
+ # per-store inbox, not a global one.
2211
+ @candidates = @agent && store ? store.candidates(agent_id: @agent, status: "awaiting_approval") : []
2212
+ @pending_gates = @agent && store ? store.candidates(agent_id: @agent, status: "pending").first(50) : []
2213
+ @blocked = if @agent && store
2214
+ store.candidates(agent_id: @agent, status: "gated")
2215
+ .select { |c| !c.eval_gate || !c.conversion_gate || c.conversion_gate["passed"] == false }
2216
+ .first(50)
2217
+ else
2218
+ []
2219
+ end
2220
+ @recent = @agent && store ? store.candidates(agent_id: @agent, status: "promoted").first(20) : []
2221
+ @runs = @agent && store ? store.runs_for(@agent, limit: 5) : []
2222
+ @promotions = @agent && store ? store.promotions(agent_id: @agent, limit: 50) : []
2223
+ @criterion = insika[:harvest_criterion]
2224
+ @funnel = insika[:funnel_store]
2225
+ @sessions = insika[:session_store]
2226
+ @negative = insika[:negative_list]
2227
+ @filter = presence(request.params["agent"])
2228
+ view("harvest")
2229
+ end
2230
+
2231
+ # The evidence excerpt for a candidate — the origin sessions' messages at
2232
+ # the stored indexes, read at request time (D7: ids and indexes only).
2233
+ #
2234
+ # An index is validated by the miner as "fits at least one origin
2235
+ # session", so it must NOT be rendered against every origin session — the
2236
+ # same index would point at different conversations (the review fix).
2237
+ # Each index renders ONE message, from the first origin session where it
2238
+ # is in range, labeled with that session.
2239
+ def candidate_excerpt(candidate)
2240
+ Array(candidate.evidence_turns).filter_map do |i|
2241
+ session = Array(candidate.origin).filter_map { |sid| @sessions&.find(sid) }
2242
+ .find { |s| s.messages && s.messages[i] }
2243
+ next unless session
2244
+
2245
+ [session.id, i, session.messages[i]["content"].to_s]
2246
+ end
2247
+ end
2248
+
2249
+ # The negative list's rejection counts per rule, read from the RUN
2250
+ # records (D4 — the counts the Studio shows are what the engine logged:
2251
+ # the run carries { rule_id => count, "ungrounded" => N, ... }).
2252
+ def negative_rule_counts
2253
+ store = insika[:harvest_store]
2254
+ return {} unless store
2255
+
2256
+ store.runs_for(@agent || "", limit: 200).each_with_object(Hash.new(0)) do |run, acc|
2257
+ Array(run.rejected).each { |rule, n| acc[rule] += n.to_i }
2258
+ end
2259
+ end
2260
+
2261
+ # The excerpt for a proposal's evidence — the transcript messages at the
2262
+ # stored indexes, read at request time (D7: ids and indexes only are
2263
+ # stored). Deep-links to the existing session page.
2264
+ def proposal_excerpt(proposal)
2265
+ session = @sessions&.find(proposal.session_ref)
2266
+ return [] unless session && session.messages
2267
+
2268
+ Array(proposal.evidence).filter_map do |i|
2269
+ session.messages[i] && session.messages[i]["content"].to_s
2270
+ end
2271
+ end
2272
+
2273
+ # The agent's records across every status (a nil store reads as none).
2274
+ def followup_records(agent_id, _decl)
2275
+ store = insika[:followup_store]
2276
+ return [] unless store
2277
+
2278
+ store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
2279
+ agent: agent_id)
2280
+ end
2281
+
2282
+ # The A/B readout per arm (D3 — a read-only fold over the same records):
2283
+ # sent = fired count in the period;
2284
+ # conversions = outcome records whose session_id is a fired record's
2285
+ # session (primary-filtered by the declaration
2286
+ # when one exists, else the first outcome per session);
2287
+ # opt-outs = contact cells flipped :revoked after a fired_at.
2288
+ # The note anchors the ruler outside the card: the A/B is the operator's;
2289
+ # this card reads the baseline.
2290
+ def followup_ab(agent_id, _decl)
2291
+ store = insika[:followup_store]
2292
+ return {} unless store
2293
+
2294
+ from = (Time.now.utc - 30 * 86_400).iso8601
2295
+ fired = store.for_agent(tenant: presence(request.params["tenant"]) || "platform",
2296
+ agent: agent_id)
2297
+ .select { |r| r.status == "fired" && r.at.to_s >= from }
2298
+ fired.group_by(&:arm).each_with_object({}) do |(arm, records), acc|
2299
+ sessions = records.filter_map(&:session_id)
2300
+ acc[arm] = { sent: records.size, conversions: conversions_for(agent_id, sessions),
2301
+ opt_outs: opt_outs_since(records) }
2302
+ end
2303
+ end
2304
+
2305
+ # The conversions of the arm's fired sessions. Primary-filtered by the
2306
+ # funnel declaration when the agent declares one; else the FIRST
2307
+ # outcome per session (an outcome record is one vote per session).
2308
+ def conversions_for(agent_id, sessions)
2309
+ outcomes = insika[:outcome_store]
2310
+ return 0 unless outcomes
2311
+
2312
+ records = sessions.empty? ? [] : outcomes.all.select { |r| sessions.include?(r.session_id) }
2313
+ primary = primary_stage_for(agent_id)
2314
+ return records.count { |r| r.outcome == primary } if primary
2315
+
2316
+ records.group_by(&:session_id).size
2317
+ end
2318
+
2319
+ def primary_stage_for(agent_id)
2320
+ profile = insika[:profile_source]&.fetch(agent_id)
2321
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2322
+ decl && decl.primary
2323
+ end
2324
+
2325
+ # The opt-outs of ONE arm: contact cells of the customers THAT ARM fired
2326
+ # to (same tenant), flipped :revoked after that customer's own fire. Never
2327
+ # a global revoked count — an unrelated opt-out (another tenant, another
2328
+ # customer) must not count against an arm, or the H-followup kill decision
2329
+ # reads strangers.
2330
+ def opt_outs_since(records)
2331
+ store = insika[:contact_store]
2332
+ return 0 unless store
2333
+
2334
+ cells = store.cells
2335
+ # group this arm's fires by (tenant, customer) -> the earliest fire
2336
+ records.group_by { |r| [r.tenant, r.customer] }.sum do |(tenant, customer), recs|
2337
+ fired_at = recs.filter_map { |r| parse_time(r.fired_at || r.at) }.min
2338
+ next 0 if fired_at.nil?
2339
+
2340
+ cell = cells["#{tenant}:#{customer}"]
2341
+ cell && cell["state"] == "revoked" && revoke_after?(cell, fired_at) ? 1 : 0
2342
+ end
2343
+ end
2344
+
2345
+ def revoke_after?(cell, fired_at)
2346
+ updated = parse_time(cell["updated_at"])
2347
+ updated && updated > fired_at
2348
+ end
2349
+
2350
+ def parse_time(value)
2351
+ Time.iso8601(value.to_s)
2352
+ rescue ArgumentError
2353
+ nil
2354
+ end
2355
+
2356
+ # Tenant list per agent, derived from the store cells (D7, the 0031 drill
2357
+ # pattern) — multi-tenant deployments show one card block per tenant; a
2358
+ # single-tenant deployment just "platform".
2359
+ def tenants_for(agent_id)
2360
+ cells = insika[:funnel_store]&.pairs.to_a || []
2361
+ cells.select { |c| c[:agent] == agent_id }.map { |c| c[:tenant] }
2362
+ end
2363
+
2364
+ # Sums the folded counts over the period -> { stage => count } for the view.
2365
+ # A nil store (base wiring) reads as all-zero — the page still renders the
2366
+ # declaration and the freeze form.
2367
+ def funnel_period_counts(store, tenant, agent, period)
2368
+ return {} unless store
2369
+
2370
+ today = Date.today.iso8601
2371
+ from = (Date.today - period).iso8601
2372
+ store.days(tenant: tenant, agent: agent, from: from, to: today)
2373
+ .each_with_object(Hash.new(0)) { |(_, counts), acc| counts.each { |s, n| acc[s] += n } }
2374
+ end
2375
+
2376
+ # BudgetLedger's current counters (D6): "tokens today / this month". nil
2377
+ # store -> the view renders 0 (the empty state).
2378
+ def funnel_spend(store, tenant, agent)
2379
+ return { daily: 0, monthly: 0 } unless store
2380
+
2381
+ store.current(tenant: tenant, agent: agent)
2382
+ end
2383
+
2384
+ # the session's stage history — each outcome record of the
2385
+ # session folded to its stage by the agent's declaration (unmapped kinds
2386
+ # render the raw outcome with a muted note — the hole is visible).
2387
+ # Without a declaration the block renders the raw outcomes.
2388
+ def stage_history_for(sid)
2389
+ records = Array(insika[:outcome_store]&.all&.select { |r| r.session_id == sid })
2390
+ .sort_by { |r| r.at.to_s }
2391
+ return [] if records.empty?
2392
+
2393
+ agent = records.first.agent
2394
+ profile = insika[:profile_source]&.fetch(agent)
2395
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
2396
+ {
2397
+ declaration: decl,
2398
+ rows: records.map do |r|
2399
+ stage = decl && decl.advance_on[r.outcome]
2400
+ { outcome: r.outcome, stage: stage, at: r.at, value: r.value }
2401
+ end
2402
+ }
2403
+ end
2404
+
1437
2405
  # A run's window in words. An EMPTY window means "the collector's own default" —
1438
2406
  # resolve it for the operator instead of rendering a blank.
1439
2407
  def refinement_window(window)
@@ -1484,7 +2452,7 @@ end
1484
2452
 
1485
2453
  def task_path(id) = "/studio/tasks/#{Rack::Utils.escape(id.to_s)}"
1486
2454
 
1487
- # parse_kv_lines moved to Studio::Forms (§11 B6).
2455
+ # parse_kv_lines moved to Studio::Forms.
1488
2456
 
1489
2457
  # CSV/whitespace -> [String], blanks dropped.
1490
2458
  def split_list(str)
@@ -1519,11 +2487,26 @@ end
1519
2487
  "/studio/playground?#{query}"
1520
2488
  end
1521
2489
 
1522
- def agent_path(id, anchor = nil)
2490
+ def agent_path(id, anchor = nil, query = nil)
1523
2491
  base = "/studio/agents/#{Rack::Utils.escape(id)}"
2492
+ base += "?#{query}" if query
1524
2493
  anchor ? "#{base}##{anchor}" : base
1525
2494
  end
1526
2495
 
2496
+ # Deterministic avatar hue: the id's fingerprint, so agents/sessions are
2497
+ # scannable at a glance (Linear-style). CSP forbids inline styles, so the
2498
+ # hue is a class: avatar-h0..avatar-h9 in application.css.
2499
+ def avatar_class(id)
2500
+ "avatar-h#{id.to_s.bytes.sum % 10}"
2501
+ end
2502
+
2503
+ # Where a prompt POST lands back: the same file open in the drill, scrolled
2504
+ # to the prompts section. No file (a delete, say) → the agent page itself.
2505
+ def prompt_edit_path(id, file)
2506
+ name = presence(file)
2507
+ name ? "#{agent_path(id)}/prompts/#{Rack::Utils.escape(name)}#prompts" : agent_path(id, "prompts")
2508
+ end
2509
+
1527
2510
  # Serves a versioned asset from dist. `File.basename` kills path traversal; only
1528
2511
  # files that exist in the dist dir are served.
1529
2512
  def serve_asset(name)