insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -81,10 +81,18 @@ 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
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,
@@ -94,6 +102,9 @@ module Studio
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,
@@ -104,6 +115,10 @@ module Studio
104
115
  # per-session context breakdown: tokens by category +
105
116
  # budget per turn, on the same viewer. Counts only, no content.
106
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,
107
122
  # operate: tasks + human-in-the-loop approvals. Reads the
108
123
  # task/checkpoint/pending stores to render; controls (pause/resume/
109
124
  # cancel/approve) dispatch on the bus — parity with server/admin.
@@ -113,7 +128,37 @@ module Studio
113
128
  # Read-only here; the "Run" button dispatches :run_refinement on the bus.
114
129
  refinement_store: refinement_store,
115
130
  # eval cases: read to render; writes go through:write_golden.
116
- golden_store: golden_store
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
117
162
  }.freeze
118
163
  # "restart recommended" flag — in memory, PER PROCESS. A
119
164
  # config change that the runtime only re-reads at boot (e.g.: MCP instances are
@@ -233,11 +278,15 @@ module Studio
233
278
  r.is do
234
279
  r.get do
235
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
236
285
  view("agents")
237
286
  end
238
287
 
239
288
  # POST /studio/agents — creates an agent ("everyone creates
240
- # 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.
241
290
  r.post do
242
291
  check_csrf!
243
292
  id = presence(r.params["id"])
@@ -269,20 +318,86 @@ module Studio
269
318
  with_flash("Configuration saved.") do
270
319
  dispatch(:update_agent, config_patch(r))
271
320
  end
272
- 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"))
273
374
  end
274
375
 
275
376
  # Store-backed prompts. Writing also ensures the file
276
377
  # enters `prompt_files` — otherwise the Prompt provider wouldn't load it.
277
378
  # prompt_files is synced by the Commands themselves (write/delete
278
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.
279
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
+
280
395
  r.post "delete" do
281
396
  check_csrf!
282
397
  with_flash("Prompt removed.") do
283
398
  dispatch(:delete_agent_file, { agent_id: id, file: presence(r.params["file"]) })
284
399
  end
285
- r.redirect(agent_path(id))
400
+ r.redirect(agent_path(id, "prompts"))
286
401
  end
287
402
 
288
403
  r.post "restore" do
@@ -293,7 +408,7 @@ module Studio
293
408
  version: r.params["version"]
294
409
  })
295
410
  end
296
- r.redirect(agent_path(id))
411
+ r.redirect(prompt_edit_path(id, r.params["file"]))
297
412
  end
298
413
 
299
414
  r.post do
@@ -303,7 +418,7 @@ module Studio
303
418
  agent_id: id, file: presence(r.params["file"]), content: r.params["content"].to_s
304
419
  })
305
420
  end
306
- r.redirect(agent_path(id))
421
+ r.redirect(prompt_edit_path(id, r.params["file"]))
307
422
  end
308
423
  end
309
424
 
@@ -358,7 +473,7 @@ module Studio
358
473
 
359
474
  # Agent memory. Scoped by tenant = agent id — the
360
475
  # SAME tenant the playground uses when chatting, so what is edited
361
- # here is what the BIA reads on the turn. Each agent, its own memory.
476
+ # here is what the agent reads on the turn. Each agent, its own memory.
362
477
  r.on "memory" do
363
478
  r.post "fact" do
364
479
  check_csrf!
@@ -674,11 +789,90 @@ module Studio
674
789
  .group_by { |t| t["turn"] }
675
790
  # Context breakdown per turn: chronological entries.
676
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)
677
795
  view("session")
678
796
  end
679
797
  end
680
798
  end
681
799
 
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
+
682
876
  # Tasks: list + detail + operator controls -------
683
877
  # Parity with server/admin: READS the task/checkpoint/pending stores to
684
878
  # render; pause/resume/cancel dispatch Commands on the bus (never a direct
@@ -803,6 +997,156 @@ end
803
997
  end
804
998
  end
805
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
+
806
1150
  # Playground: sends `send_message` (the SAME Command as the API) and streams the
807
1151
  # response live through the `live-transcript` island (SSE from /studio/events).
808
1152
  r.on "playground" do
@@ -810,6 +1154,14 @@ end
810
1154
  @agent = presence(r.params["agent"]) || default_agent
811
1155
  @session_id = presence(r.params["session_id"])
812
1156
  @agents = insika[:profile_source].ids.sort
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
813
1165
  # Server-side echo + continuity: render the session's persisted
814
1166
  # transcript as bubbles. The user's message is only persisted at the END
815
1167
  # of the turn, so the just-sent message rides a one-shot flash bubble
@@ -876,12 +1228,24 @@ end
876
1228
  ]],
877
1229
  ["operate", [
878
1230
  ["Chats", "/studio/chats", :chats],
1231
+ ["Customers", "/studio/customers", :chats],
879
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],
880
1243
  ["Tasks", "/studio/tasks", :tasks],
881
1244
  ["Approvals", "/studio/approvals", :approvals],
882
1245
  ["Refinement", "/studio/refinement", :refinement],
883
1246
  ["Evals", "/studio/evals", :evals]
884
- ]]
1247
+ ] + parity_nav_item
1248
+ ]
885
1249
  ]
886
1250
  end
887
1251
 
@@ -962,7 +1326,7 @@ end
962
1326
 
963
1327
  def default_agent
964
1328
  ids = insika[:profile_source].ids
965
- ids.include?("bia") ? "bia" : (ids.first || "bia")
1329
+ ids.include?("demo") ? "demo" : (ids.first || "demo")
966
1330
  end
967
1331
 
968
1332
  # --- Transcript display helpers (playground + session viewer) ------------
@@ -1049,6 +1413,7 @@ end
1049
1413
 
1050
1414
  def render_agent_detail
1051
1415
  id = @agent.id
1416
+ @config_group = CONFIG_SECTIONS.include?(request.params["cfg"]) ? request.params["cfg"] : "model"
1052
1417
  store = insika[:agent_file_store]
1053
1418
  @prompt_files = Array(@agent.prompt_files).map do |name|
1054
1419
  {
@@ -1069,6 +1434,14 @@ end
1069
1434
  @facts = mem ? mem.facts(tenant: id) : []
1070
1435
  @notes = mem ? mem.notes(tenant: id, limit: 20) : []
1071
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) || []
1072
1445
  view("agent_detail")
1073
1446
  end
1074
1447
 
@@ -1080,6 +1453,93 @@ end
1080
1453
  params[key.to_s].nil? ? "" : params[key.to_s]
1081
1454
  end
1082
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
+
1083
1543
  # Renders the reasoning <select> shared by the agent config, settings and
1084
1544
  # playground (4-layer). `blank_label` names the empty option — the
1085
1545
  # "inherit the broader layer" / provider-default choice. Values come from a
@@ -1364,9 +1824,12 @@ end
1364
1824
  # At-a-glance dashboard: counts + activity + recent conversations. All from
1365
1825
  # data the Studio already reads (one scan of the session store); no new
1366
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"]`).
1367
1829
  def render_home
1368
1830
  ps = insika[:profile_source]
1369
- sessions = all_sessions
1831
+ @agent = presence(request.params["agent"])
1832
+ sessions = agent_sessions(all_sessions, @agent)
1370
1833
  @counts = {
1371
1834
  "conversations" => sessions.size,
1372
1835
  "messages" => sessions.sum { |s| Array(s.messages).size },
@@ -1446,6 +1909,10 @@ end
1446
1909
 
1447
1910
  # --- Settings + LLM providers ----------------------------------
1448
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
+
1449
1916
  SETTINGS_SECTIONS = %w[general models edge evals llm].freeze
1450
1917
  def render_settings
1451
1918
  store = insika[:settings_store]
@@ -1483,20 +1950,67 @@ end
1483
1950
  # --- Chats -----------------------------------------------------
1484
1951
 
1485
1952
  def render_chats
1486
- @sessions = recent_sessions(limit: 100)
1953
+ @agent = presence(request.params["agent"])
1954
+ @sessions = agent_sessions(recent_sessions(limit: 100), @agent)
1487
1955
  view("chats")
1488
1956
  end
1489
1957
 
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
+
1490
1996
  # Tasks & Approvals --------------------------------
1491
1997
 
1492
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).
1493
2000
  def render_tasks
1494
2001
  store = insika[:task_store]
2002
+ @agent = presence(request.params["agent"])
1495
2003
  @tasks = store ? store.each_id.filter_map { |id| store.find(id) } : []
2004
+ @tasks = @tasks.select { |t| task_agent(t) == @agent } if @agent
1496
2005
  @tasks = @tasks.sort_by { |t| t.updated_at.to_s }.reverse
1497
2006
  view("tasks")
1498
2007
  end
1499
2008
 
2009
+ def task_agent(task)
2010
+ command = task.command
2011
+ command.is_a?(Hash) ? command.dig("payload", "agent").to_s : ""
2012
+ end
2013
+
1500
2014
  # Task detail: @task is set by the route. Adds the open approvals for this task
1501
2015
  # and its latest checkpoint (both degrade to empty when the store is absent).
1502
2016
  def render_task_detail(id)
@@ -1506,11 +2020,16 @@ end
1506
2020
  end
1507
2021
 
1508
2022
  # Approvals inbox: every :pending action across tasks, each paired with its
1509
- # 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).
1510
2025
  def render_approvals
1511
2026
  pstore = insika[:pending_action_store]
1512
2027
  tstore = insika[:task_store]
2028
+ @agent = presence(request.params["agent"])
1513
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
1514
2033
  view("approvals")
1515
2034
  end
1516
2035
 
@@ -1523,6 +2042,8 @@ def render_evals
1523
2042
  store = insika[:golden_store]
1524
2043
  @cases = store ? store.all : []
1525
2044
  @invalid = store ? store.invalid : []
2045
+ @filter = presence(request.params["agent"])
2046
+ @cases = @cases.select { |g| g.agent == @filter } if @filter
1526
2047
  @by_agent = @cases.group_by(&:agent).sort.to_h
1527
2048
  wanted = presence(request.params["id"])
1528
2049
  @case = wanted && @cases.find { |g| g.id == wanted }
@@ -1556,6 +2077,331 @@ end
1556
2077
  view("refinement")
1557
2078
  end
1558
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
+
1559
2405
  # A run's window in words. An EMPTY window means "the collector's own default" —
1560
2406
  # resolve it for the operator instead of rendering a blank.
1561
2407
  def refinement_window(window)
@@ -1641,11 +2487,26 @@ end
1641
2487
  "/studio/playground?#{query}"
1642
2488
  end
1643
2489
 
1644
- def agent_path(id, anchor = nil)
2490
+ def agent_path(id, anchor = nil, query = nil)
1645
2491
  base = "/studio/agents/#{Rack::Utils.escape(id)}"
2492
+ base += "?#{query}" if query
1646
2493
  anchor ? "#{base}##{anchor}" : base
1647
2494
  end
1648
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
+
1649
2510
  # Serves a versioned asset from dist. `File.basename` kills path traversal; only
1650
2511
  # files that exist in the dist dir are served.
1651
2512
  def serve_asset(name)