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
@@ -1,26 +1,66 @@
1
1
  <header class="page-head">
2
2
  <div>
3
3
  <nav class="crumbs"><a href="/studio/agents">agents</a> / <span><%= @agent.id %></span></nav>
4
- <h1><span class="avatar sm"><%= @agent.id[0, 2].upcase %></span><%= @agent.id %>
4
+ <h1><span class="avatar sm <%= avatar_class(@agent.id) %>"><%= @agent.id[0, 2].upcase %></span><%= @agent.id %>
5
5
  <span class="sub"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
6
6
  </h1>
7
7
  </div>
8
8
  <a class="btn primary" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">Open playground →</a>
9
9
  </header>
10
10
 
11
- <nav class="subnav">
12
- <a href="#config">config</a>
13
- <a href="#prompts">prompts</a>
14
- <a href="#skills">skills</a>
15
- <a href="#memory">memory</a>
16
- <a href="#history">history</a>
11
+ <div class="tabs-page" data-controller="tabs">
12
+ <nav class="subnav" aria-label="agent sections">
13
+ <a href="#config" data-tabs-target="tab">config</a>
14
+ <a href="#prompts" data-tabs-target="tab">prompts</a>
15
+ <a href="#skills" data-tabs-target="tab">skills</a>
16
+ <a href="#memory" data-tabs-target="tab">memory</a>
17
+ <a href="#outcomes" data-tabs-target="tab">outcomes</a>
18
+ <a href="#loops" data-tabs-target="tab">loops</a>
19
+ <a href="#cache" data-tabs-target="tab">cache</a>
20
+ <a href="#history" data-tabs-target="tab">history</a>
17
21
  </nav>
18
22
 
19
23
  <!-- ── Config / model ────────────────────────────────────────────────────── -->
20
- <section class="section" id="config">
21
- <div class="section-head"><h2>Configuration</h2><p class="muted">model, provider, memory, limits, generation params and the model fence — saved via <code>update_agent</code>.</p></div>
22
- <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/config">
23
- <%== csrf_tag %>
24
+ <section class="section" id="config" data-tabs-target="panel">
25
+ <% config_groups = [
26
+ ["model", "Model & limits", "model, provider, timeouts, ceilings, generation, fence"],
27
+ ["guardrails", "Guardrails", "input/output checks, strictness, safe replies"],
28
+ ["grounding", "Grounding", "evidence behind product claims"],
29
+ ["funnel", "Outcome funnel", "stage vocabulary"],
30
+ ["followups", "Follow-ups", "frequency, quiet hours, silence"],
31
+ ["distill", "Distillation", "finished chats → fact proposals"],
32
+ ["harvest", "Harvest", "traffic → skill candidates"],
33
+ ["refinement", "Refinement", "ranked findings, proposed edits"],
34
+ ["budget_rel", "Budget & reliability", "spend caps, retries, fallback, breaker"],
35
+ ["routing", "Routing & media", "routes, outputs, alerts, the streaming edge"],
36
+ ["advanced", "Advanced", "capabilities, allowlists, behaviour, metadata"]
37
+ ]
38
+ active = config_groups.find { |k, _| k == @config_group } || config_groups.first
39
+ @config_group = active[0] %>
40
+ <div class="section-head"><h2>Configuration</h2><p class="muted">every profile field, editable hot — pick a group to the left — saved via <code>update_agent</code>.</p></div>
41
+ <div class="drill config-drill">
42
+ <aside class="drill-pane drill-master">
43
+ <div class="drill-pane-head"><div class="title"><strong>Configuration</strong></div></div>
44
+ <nav class="drill-pane-body drill-list" aria-label="config groups">
45
+ <% config_groups.each do |key, label, sub| %>
46
+ <a class="drill-item<%== ' active' if @config_group == key %>"
47
+ href="<%= agent_path(@agent.id, "config", "cfg=#{key}") %>">
48
+ <div class="drill-item-name"><span class="txt"><%= label %></span></div>
49
+ <div class="drill-item-sub"><%= sub %></div>
50
+ </a>
51
+ <% end %>
52
+ </nav>
53
+ </aside>
54
+ <section class="drill-pane drill-detail">
55
+ <div class="drill-pane-head">
56
+ <div class="title"><strong><%= active[1] %></strong></div>
57
+ <button type="submit" form="config-form" class="btn primary" data-turbo-submits-with="Saving…">Save config</button>
58
+ </div>
59
+ <div class="drill-pane-body">
60
+ <form id="config-form" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/config">
61
+ <%== csrf_tag %>
62
+ <input type="hidden" name="cfg" value="<%= @config_group %>">
63
+ <div class="config-group" data-config-group="model"<%== " hidden" unless @config_group == "model" %>>
24
64
  <div class="grid-2">
25
65
  <label>model <span class="muted">(blank = platform <code>default_model</code>)</span>
26
66
  <input type="text" name="model" value="<%= @agent.model %>" placeholder="inherit platform default">
@@ -46,7 +86,7 @@
46
86
  </div>
47
87
  <label class="check">
48
88
  <input type="checkbox" name="memory" value="1"<%== " checked" if @agent.memory %>>
49
- cross-session memory (the BIA remembers across conversations)
89
+ cross-session memory (the agent remembers across conversations)
50
90
  </label>
51
91
 
52
92
  <fieldset class="subgroup">
@@ -67,7 +107,9 @@
67
107
  <label>model fence <span class="muted">(<code>model_policy.allow</code> — one ref per line: <code>provider/model</code>, <code>provider/*</code> or <code>model</code>; blank = no fence)</span>
68
108
  <textarea name="model_policy_allow" rows="3" placeholder="deepseek/*&#10;openai/gpt-4o-mini"><%= @model_policy_allow %></textarea>
69
109
  </label>
110
+ </div>
70
111
 
112
+ <div class="config-group" data-config-group="guardrails"<%== " hidden" unless @config_group == "guardrails" %>>
71
113
  <fieldset class="subgroup">
72
114
  <legend>guardrails <span class="muted">— content safety</span></legend>
73
115
  <label class="check">
@@ -106,95 +148,482 @@
106
148
  </div>
107
149
  </details>
108
150
  </fieldset>
151
+ </div>
109
152
 
110
- <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Saving…">Save config</button></div>
111
- </form>
153
+ <div class="config-group" data-config-group="grounding"<%== " hidden" unless @config_group == "grounding" %>>
154
+ <fieldset class="subgroup">
155
+ <legend>grounding <span class="muted">— product claims must point at evidence the session actually saw</span></legend>
156
+ <% gr = @agent.grounding || {} %>
157
+ <div class="grid-2">
158
+ <label>mode <span class="muted">(blank = off)</span>
159
+ <select name="grounding_mode">
160
+ <option value="">off</option>
161
+ <% %w[flag enforce].each do |m| %>
162
+ <option value="<%= m %>"<%== ' selected' if gr["mode"] == m %>><%= m %></option>
163
+ <% end %>
164
+ </select>
165
+ </label>
166
+ <label>matcher SKU <span class="muted">(evidence id prefix, e.g. <code>sku:</code>)</span>
167
+ <input type="text" name="grounding_matcher_sku" value="<%= gr.dig("matcher", "sku") %>" placeholder="sku:">
168
+ </label>
169
+ <label>name keys <span class="muted">(comma-separated metadata keys with product names)</span>
170
+ <input type="text" name="grounding_name_keys" value="<%= agent_list(gr.dig("matcher", "name_keys")) %>" placeholder="product, name">
171
+ </label>
172
+ </div>
173
+ </fieldset>
174
+ </div>
175
+
176
+ <div class="config-group" data-config-group="funnel"<%== " hidden" unless @config_group == "funnel" %>>
177
+ <fieldset class="subgroup">
178
+ <legend>outcome funnel <span class="muted">— the stage vocabulary the engine folds WS7 outcomes into</span></legend>
179
+ <% fun = @agent.funnel || {} %>
180
+ <div class="grid-2">
181
+ <label>stages <span class="muted">(one per line, in order; blank = no funnel)</span>
182
+ <textarea name="funnel_stages" rows="4" placeholder="greeted&#10;qualified&#10;cart&#10;paid"><%= agent_lines(fun["stages"]) %></textarea>
183
+ </label>
184
+ <div class="grid-2">
185
+ <label>primary
186
+ <input type="text" name="funnel_primary" value="<%= fun["primary"] %>" placeholder="paid">
187
+ </label>
188
+ <label>attribution window <span class="muted">(carried, never computed)</span>
189
+ <input type="text" name="funnel_attribution_window" value="<%= fun["attribution_window"] %>" placeholder="72h">
190
+ </label>
191
+ <label class="full">
192
+ advance_on <span class="muted">— JSON: outcome kind → reached stage</span>
193
+ <textarea name="funnel_advance_on" rows="4" spellcheck="false" class="mono" placeholder='{ "pix_paid": "paid" }'><%= agent_json(fun["advance_on"]) %></textarea>
194
+ </label>
195
+ </div>
196
+ </div>
197
+ </fieldset>
198
+ </div>
199
+
200
+ <div class="config-group" data-config-group="followups"<%== " hidden" unless @config_group == "followups" %>>
201
+ <fieldset class="subgroup">
202
+ <legend>follow-ups <span class="muted">— the seller who comes back (blank arm = off)</span></legend>
203
+ <% fu = @agent.followup || {}; fup = fu["policy"] || {}; fqh = fup["quiet_hours"] || {} %>
204
+ <div class="grid-2">
205
+ <label>arm
206
+ <input type="text" name="followup_arm" value="<%= fu["arm"] %>" placeholder="schedule">
207
+ </label>
208
+ <label>max frequency <span class="muted">(e.g. <code>2/24h</code>)</span>
209
+ <input type="text" name="followup_max_frequency" value="<%= fup["max_frequency"] %>" placeholder="2/24h">
210
+ </label>
211
+ <label>quiet hours timezone <span class="muted">(blank = no quiet hours)</span>
212
+ <input type="text" name="followup_tz" value="<%= fqh["timezone"] %>" placeholder="America/Sao_Paulo">
213
+ </label>
214
+ <div class="grid-2">
215
+ <label>quiet start
216
+ <input type="text" name="followup_quiet_start" value="<%= fqh["start"] %>" placeholder="21:30">
217
+ </label>
218
+ <label>quiet end
219
+ <input type="text" name="followup_quiet_end" value="<%= fqh["end"] %>" placeholder="09:00">
220
+ </label>
221
+ </div>
222
+ <label>cancel keywords <span class="muted">(comma-separated)</span>
223
+ <input type="text" name="followup_cancel_keywords" value="<%= agent_list(fup["cancel_keywords"]) %>" placeholder="não quero mais contato">
224
+ </label>
225
+ <label>silence after N sends
226
+ <input type="text" name="followup_silence_after_sends" value="<%= fup["silence_after_sends"] %>" placeholder="3" inputmode="numeric">
227
+ </label>
228
+ </div>
229
+ </fieldset>
230
+ </div>
231
+
232
+ <div class="config-group" data-config-group="distill"<%== " hidden" unless @config_group == "distill" %>>
233
+ <fieldset class="subgroup">
234
+ <legend>distillation <span class="muted">— finished conversations → proposed customer facts (Facts page)</span></legend>
235
+ <% dist = @agent.distill || {} %>
236
+ <label class="check">
237
+ <input type="checkbox" name="distill_enabled" value="1"<%== " checked" if Insika::Coercion.truthy?(dist["enabled"]) %>>
238
+ distillation on — idle sessions are read back as fact proposals; a human always approves
239
+ </label>
240
+ <div class="grid-3">
241
+ <label>idle hours <span class="muted">(blank = 6)</span>
242
+ <input type="text" name="distill_idle_hours" value="<%= dist["idle_hours"] %>" placeholder="6" inputmode="numeric">
243
+ </label>
244
+ <label>min messages <span class="muted">(blank = 3)</span>
245
+ <input type="text" name="distill_min_messages" value="<%= dist["min_messages"] %>" placeholder="3" inputmode="numeric">
246
+ </label>
247
+ <label>max proposals <span class="muted">(blank = 10)</span>
248
+ <input type="text" name="distill_max_proposals" value="<%= dist["max_proposals"] %>" placeholder="10" inputmode="numeric">
249
+ </label>
250
+ </div>
251
+ <label>model <span class="muted">(blank = the platform utility_model)</span>
252
+ <input type="text" name="distill_model" value="<%= dist["model"] %>" placeholder="deepseek-v4-flash">
253
+ </label>
254
+ <label>prompt <span class="muted">— what counts as a fact for THIS store (blank = the engine default)</span>
255
+ <textarea name="distill_prompt" rows="3" placeholder="what counts as a customer fact for this store"><%= dist["prompt"] %></textarea>
256
+ </label>
257
+ </fieldset>
258
+ </div>
259
+
260
+ <div class="config-group" data-config-group="harvest"<%== " hidden" unless @config_group == "harvest" %>>
261
+ <fieldset class="subgroup">
262
+ <legend>harvest <span class="muted">— finished traffic → proposed SKILLs, double-gated, human-promoted (Harvest page)</span></legend>
263
+ <% hv = @agent.harvest || {}; hvm = hv["miner"] || {} %>
264
+ <label class="check">
265
+ <input type="checkbox" name="harvest_enabled" value="1"<%== " checked" if Insika::Coercion.truthy?(hv["enabled"]) %>>
266
+ harvest on — idle conversations are mined for skills; a human promotes every one
267
+ </label>
268
+ <div class="grid-3">
269
+ <label>idle hours <span class="muted">(blank = 24)</span>
270
+ <input type="text" name="harvest_idle_hours" value="<%= hv["idle_hours"] %>" placeholder="24" inputmode="numeric">
271
+ </label>
272
+ <label>min messages <span class="muted">(blank = 3)</span>
273
+ <input type="text" name="harvest_min_messages" value="<%= hv["min_messages"] %>" placeholder="3" inputmode="numeric">
274
+ </label>
275
+ <label>miner model <span class="muted">(blank = the platform utility_model)</span>
276
+ <input type="text" name="harvest_miner_model" value="<%= hvm["model"] %>" placeholder="deepseek-v4-flash">
277
+ </label>
278
+ <label>miner window <span class="muted">(last N conversations)</span>
279
+ <input type="text" name="harvest_miner_window" value="<%= hvm.dig("window", "last_sessions") %>" placeholder="200" inputmode="numeric">
280
+ </label>
281
+ <label>miner max proposals
282
+ <input type="text" name="harvest_miner_max_proposals" value="<%= hvm["max_proposals"] %>" placeholder="10" inputmode="numeric">
283
+ </label>
284
+ <label>miner budget (tokens)
285
+ <input type="text" name="harvest_miner_budget_tokens" value="<%= hvm.dig("budget", "tokens") %>" placeholder="20000" inputmode="numeric">
286
+ </label>
287
+ </div>
288
+ <label>negative list <span class="muted">— JSON: <code>[{ "rule": "…", "pattern": "…" }]</code> (blank = the deployment seed)</span>
289
+ <textarea name="harvest_negative_list" rows="3" spellcheck="false" class="mono" placeholder='[ { "rule": "no-competitor-prices", "pattern": "concorrente" } ]'><%= agent_json(hv["negative_list"]) %></textarea>
290
+ </label>
291
+ <label>prompt <span class="muted">— what a harvestable skill is for THIS store (blank = the engine default)</span>
292
+ <textarea name="harvest_prompt" rows="3" placeholder="what makes a skill harvestable for this store"><%= hv["prompt"] %></textarea>
293
+ </label>
294
+ </fieldset>
295
+ </div>
296
+
297
+ <div class="config-group" data-config-group="refinement"<%== " hidden" unless @config_group == "refinement" %>>
298
+ <fieldset class="subgroup">
299
+ <legend>refinement <span class="muted">— read traffic back as a ranked report / proposed edits (Refinement page)</span></legend>
300
+ <% rf = @agent.refinement || {}; rfw = rf["window"] || {}; rfb = rf["budget"] || {} %>
301
+ <div class="grid-2">
302
+ <label>mode <span class="muted">(blank = report-only, nothing ever writes)</span>
303
+ <select name="refinement_mode">
304
+ <option value="">report-only</option>
305
+ <% %w[report propose auto_apply].each do |m| %>
306
+ <option value="<%= m %>"<%== ' selected' if rf["mode"] == m %>><%= m %></option>
307
+ <% end %>
308
+ </select>
309
+ </label>
310
+ <label>window <span class="muted">(last N conversations; blank = 200)</span>
311
+ <input type="text" name="refinement_window" value="<%= rfw["last_sessions"] %>" placeholder="200" inputmode="numeric">
312
+ </label>
313
+ <label>max findings
314
+ <input type="text" name="refinement_max_findings" value="<%= rf["max_findings"] %>" placeholder="20" inputmode="numeric">
315
+ </label>
316
+ <label>auto_apply max edits <span class="muted">(propose+ mode)</span>
317
+ <input type="text" name="refinement_auto_apply_max_edits" value="<%= rf["auto_apply_max_edits"] %>" inputmode="numeric">
318
+ </label>
319
+ <label>files <span class="muted">(allowlist, comma-separated)</span>
320
+ <input type="text" name="refinement_files" value="<%= agent_list(rf["files"]) %>" placeholder="IDENTITY.md">
321
+ </label>
322
+ <label>proposers <span class="muted">(model refs, comma-separated)</span>
323
+ <input type="text" name="refinement_proposers" value="<%= agent_list(rf["proposers"]) %>" placeholder="deepseek/deepseek-v4-flash">
324
+ </label>
325
+ </div>
326
+ <label>budget (tokens)
327
+ <input type="text" name="refinement_budget_tokens" value="<%= rfb["tokens"] %>" inputmode="numeric">
328
+ </label>
329
+ </fieldset>
330
+ </div>
331
+
332
+ <div class="config-group" data-config-group="budget_rel"<%== " hidden" unless @config_group == "budget_rel" %>>
333
+ <fieldset class="subgroup">
334
+ <legend>budget <span class="muted">— WS2: calendar spend caps per (tenant, agent)</span></legend>
335
+ <% bd = @agent.budget || {} %>
336
+ <div class="grid-3">
337
+ <label>daily (tokens)
338
+ <input type="text" name="budget_daily" value="<%= bd["daily"] %>" inputmode="numeric">
339
+ </label>
340
+ <label>monthly (tokens)
341
+ <input type="text" name="budget_monthly" value="<%= bd["monthly"] %>" inputmode="numeric">
342
+ </label>
343
+ <label>alert at <span class="muted">(0–1 of the cap)</span>
344
+ <input type="text" name="budget_alert_at" value="<%= bd["alert_at"] %>" placeholder="0.8" inputmode="decimal">
345
+ </label>
346
+ </div>
347
+ <label class="check">
348
+ <input type="checkbox" name="budget_soft" value="1"<%== " checked" if bd["soft"] %>>
349
+ soft cap <span class="muted">— crossing it warns and runs; hard (default) fails the turn</span>
350
+ </label>
351
+ </fieldset>
352
+
353
+ <fieldset class="subgroup">
354
+ <legend>reliability <span class="muted">— WS3: retries, fallback chain, circuit breaker</span></legend>
355
+ <% rel = @agent.reliability || {}; brk = rel["circuit_breaker"] || {} %>
356
+ <div class="grid-3">
357
+ <label>retries
358
+ <input type="text" name="reliability_retries" value="<%= rel["retries"] %>" inputmode="numeric">
359
+ </label>
360
+ <label>backoff
361
+ <select name="reliability_backoff">
362
+ <option value="">none</option>
363
+ <% %w[constant exponential].each do |m| %>
364
+ <option value="<%= m %>"<%== ' selected' if rel["backoff"] == m %>><%= m %></option>
365
+ <% end %>
366
+ </select>
367
+ </label>
368
+ <label>timeout (s)
369
+ <input type="text" name="reliability_timeout" value="<%= rel["timeout"] %>" placeholder="30" inputmode="numeric">
370
+ </label>
371
+ <label>breaker after <span class="muted">(failures)</span>
372
+ <input type="text" name="reliability_breaker_after" value="<%= brk["after"] %>" inputmode="numeric">
373
+ </label>
374
+ <label>breaker within (s)
375
+ <input type="text" name="reliability_breaker_within" value="<%= brk["within"] %>" inputmode="numeric">
376
+ </label>
377
+ <label>breaker cooldown (s)
378
+ <input type="text" name="reliability_breaker_cooldown" value="<%= brk["cooldown"] %>" inputmode="numeric">
379
+ </label>
380
+ </div>
381
+ <label>fallback models <span class="muted">(comma-separated refs, tried after retries)</span>
382
+ <input type="text" name="reliability_fallback" value="<%= agent_list(rel["fallback"]) %>" placeholder="openai/gpt-4o-mini">
383
+ </label>
384
+ </fieldset>
385
+ </div>
386
+
387
+ <div class="config-group" data-config-group="routing"<%== " hidden" unless @config_group == "routing" %>>
388
+ <fieldset class="subgroup">
389
+ <legend>routing, media, alerts &amp; the edge</legend>
390
+ <label>routes <span class="muted">— WS4 JSON: intent → description / delegate / stuck (blank = no routing)</span>
391
+ <textarea name="routes" rows="4" spellcheck="false" class="mono" placeholder='{ "shopping": "the customer wants to browse", "default": "shopping" }'><%= agent_json(@agent.routes) %></textarea>
392
+ </label>
393
+ <label>outputs <span class="muted">— WS9 JSON: generated media the agent may produce (blank = none)</span>
394
+ <textarea name="outputs" rows="3" spellcheck="false" class="mono" placeholder='{ "image": { "model": "gpt-image-1" } }'><%= agent_json(@agent.outputs) %></textarea>
395
+ </label>
396
+ <div class="grid-2">
397
+ <label>alerts webhook <span class="muted">(budget_warning / breaker_open / delivery_failed)</span>
398
+ <input type="text" name="alerts_webhook" value="<%= @agent.alerts && @agent.alerts["webhook"] %>" placeholder="https://ops.example.com/insika-alerts">
399
+ </label>
400
+ <label class="check">
401
+ <input type="checkbox" name="stuck_signal" value="1"<%== " checked" if @agent.stuck_signal %>>
402
+ stuck signal <span class="muted">— the model may end a turn <code>stuck</code> (WS5)</span>
403
+ </label>
404
+ </div>
405
+ <div class="grid-2">
406
+ <label class="check">
407
+ <input type="checkbox" name="edge_thinking" value="1"<%== " checked" if @agent.stream_public?(:thinking) %>>
408
+ stream reasoning to the customer
409
+ </label>
410
+ <label class="check">
411
+ <input type="checkbox" name="edge_intermediate" value="1"<%== " checked" if @agent.stream_public?(:intermediate) %>>
412
+ stream tool-loop narration to the customer
413
+ </label>
414
+ </div>
415
+ <p class="muted">both off = the answer only; the Studio and the trace always keep the rest.</p>
416
+ </fieldset>
417
+ </div>
418
+
419
+ <div class="config-group" data-config-group="advanced"<%== " hidden" unless @config_group == "advanced" %>>
420
+ <fieldset class="subgroup">
421
+ <legend>capabilities <span class="muted">— comma-separated; blank = none (opt-in)</span></legend>
422
+ <div class="grid-2">
423
+ <label>subagents
424
+ <input type="text" name="subagents" value="<%= agent_list(@agent.subagents) %>" placeholder="order-agent, security">
425
+ </label>
426
+ <label>capabilities
427
+ <input type="text" name="capabilities" value="<%= agent_list(@agent.capabilities) %>">
428
+ </label>
429
+ <label>tools_deferred <span class="muted">(searchable-not-wired tools)</span>
430
+ <input type="text" name="tools_deferred" value="<%= agent_list(@agent.tools_deferred) %>">
431
+ </label>
432
+ <label>briefing_fields <span class="muted">(per-session state the agent keeps)</span>
433
+ <input type="text" name="briefing_fields" value="<%= agent_list(@agent.briefing_fields) %>" placeholder="customer_name, wishlist">
434
+ </label>
435
+ </div>
436
+ </fieldset>
437
+
438
+ <fieldset class="subgroup">
439
+ <legend>allowlists <span class="muted">— comma-separated; blank = all (open) or none, per field</span></legend>
440
+ <div class="grid-2">
441
+ <label>context providers <span class="muted">(blank = all)</span>
442
+ <input type="text" name="context_providers" value="<%= agent_list(@agent.context_providers) %>">
443
+ </label>
444
+ <label>workflows <span class="muted">(blank = all)</span>
445
+ <input type="text" name="workflows_allow" value="<%= agent_list(@agent.workflows_allow) %>">
446
+ </label>
447
+ <label>approvals required <span class="muted">(tools whose call suspends for a human)</span>
448
+ <input type="text" name="approvals_required" value="<%= agent_list(@agent.approvals_required) %>" placeholder="send_discount_coupon">
449
+ </label>
450
+ <label>policies <span class="muted">(registry names)</span>
451
+ <input type="text" name="policies" value="<%= agent_list(@agent.policies) %>" placeholder="tool_allowlist, skill_allowlist">
452
+ </label>
453
+ <label>prompt refs <span class="muted">(catalog names)</span>
454
+ <input type="text" name="prompt_refs" value="<%= agent_list(@agent.prompt_refs) %>">
455
+ </label>
456
+ <label>declares <span class="muted">(facts about this deployment, read by evals)</span>
457
+ <input type="text" name="capabilities_declared" value="<%= agent_list(@agent.capabilities_declared) %>" placeholder="promotions, human_handoff">
458
+ </label>
459
+ </div>
460
+ </fieldset>
461
+
462
+ <fieldset class="subgroup">
463
+ <legend>behaviour switches</legend>
464
+ <label>skills_eager <span class="muted">(blank = progressive loading; <code>all</code> = every allowed skill each turn; or a comma list)</span>
465
+ <input type="text" name="skills_eager" value="<%= @agent.skills_eager == true ? "all" : agent_list(@agent.skills_eager) %>" placeholder="all">
466
+ </label>
467
+ <label class="check">
468
+ <input type="checkbox" name="prompt_caching" value="1"<%== " checked" if @agent.prompt_caching %>>
469
+ prompt caching <span class="muted">— Anthropic breakpoint; ON requires a byte-stable system prompt</span>
470
+ </label>
471
+ <label class="check">
472
+ <input type="checkbox" name="tool_output_compression" value="1"<%== " checked" if @agent.tool_output_compression %>>
473
+ tool output compression <span class="muted">— mechanical dedupe of repeated tool results</span>
474
+ </label>
475
+ <label class="check">
476
+ <input type="checkbox" name="tool_persistence" value="1"<%== " checked" unless @agent.tool_persistence == false %>>
477
+ tool persistence <span class="muted">— the engine's "Tool discipline" prompt block (retry weak/empty tool results differently); ON by default</span>
478
+ </label>
479
+ </fieldset>
480
+
481
+ <fieldset class="subgroup">
482
+ <legend>metadata <span class="muted">— free-form JSON, stable per agent (home of <code>store_id</code>)</span></legend>
483
+ <textarea name="metadata" rows="3" spellcheck="false" class="mono" placeholder='{ "store_id": "acme" }'><%= agent_json(@agent.metadata) %></textarea>
484
+ </fieldset>
485
+ </div>
486
+ </form>
487
+ </div>
488
+ </section>
489
+ </div>
112
490
  </section>
113
491
 
114
- <!-- ── Prompts / identity (island code-editor) ───────────────────────────── -->
115
- <section class="section" id="prompts">
492
+ <!-- ── Prompts / identity (drill: file list | editor, like skills) ────────── -->
493
+ <section class="section" id="prompts" data-tabs-target="panel">
116
494
  <div class="section-head">
117
495
  <h2>Prompts <span class="pill info plain"><%= @prompt_files.size %></span></h2>
118
- <p class="muted">the BIA's own identity (store-backed, versioned). Empty = uses the deployment default.</p>
496
+ <p class="muted">the agent's own identity (store-backed, versioned). Empty = uses the deployment default.</p>
119
497
  </div>
120
498
 
121
- <% if @prompt_files.empty? %>
122
- <div class="empty">No authored prompts. Create a file below (e.g. <code>IDENTITY.md</code>) to give this agent its own identity.</div>
123
- <% end %>
124
-
125
- <% @prompt_files.each do |pf| %>
126
- <div class="card promptfile">
127
- <div class="promptfile-head">
128
- <strong class="mono"><%= pf[:name] %></strong>
129
- <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts/delete" data-turbo-confirm="Remove prompt file &quot;<%= pf[:name] %>&quot;? This can't be undone.">
130
- <%== csrf_tag %>
131
- <input type="hidden" name="file" value="<%= pf[:name] %>">
132
- <button type="submit" class="ghost danger" data-turbo-submits-with="Removing…">Remove</button>
133
- </form>
499
+ <div class="drill" data-controller="list-filter">
500
+ <aside class="drill-pane drill-master">
501
+ <div class="drill-pane-head">
502
+ <div class="title"><strong>Prompt files <span class="count"><%= @prompt_files.size %></span></strong></div>
503
+ <a class="btn primary btn-sm" href="<%= agent_path(@agent.id) %>/prompts/new#prompts">+ new</a>
134
504
  </div>
135
- <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts" data-controller="dirty-guard">
136
- <%== csrf_tag %>
137
- <input type="hidden" name="file" value="<%= pf[:name] %>">
138
- <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
139
- <textarea name="content" data-code-editor-target="source" rows="10"><%= pf[:content] %></textarea>
505
+ <div class="drill-search">
506
+ <input type="search" class="filter-input" placeholder="Search prompts…" aria-label="Filter prompts"
507
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
508
+ </div>
509
+ <nav class="drill-pane-body drill-list" aria-label="prompt files">
510
+ <% if @prompt_files.empty? %>
511
+ <div class="empty">No prompt files yet — create the first one.</div>
512
+ <% else %>
513
+ <% @prompt_files.each do |pf| %>
514
+ <a class="drill-item<%== ' active' if @prompt_selected == pf[:name] %>"
515
+ href="<%= agent_path(@agent.id) %>/prompts/<%= Rack::Utils.escape(pf[:name]) %>#prompts"
516
+ data-list-filter-target="item" data-filter-text="<%= pf[:name] %>">
517
+ <div class="drill-item-name"><span class="txt"><%= pf[:name] %></span></div>
518
+ <div class="drill-item-sub"><%= pf[:versions].size %> version<%= 's' unless pf[:versions].size == 1 %></div>
519
+ </a>
520
+ <% end %>
521
+ <div class="empty" data-list-filter-target="empty" hidden>No prompts match your filter.</div>
522
+ <% end %>
523
+ </nav>
524
+ </aside>
525
+
526
+ <section class="drill-pane drill-detail">
527
+ <% if @prompt_selected.nil? %>
528
+ <div class="drill-empty">
529
+ <div>
530
+ <h2>Prompts</h2>
531
+ <p>Pick a prompt file on the left to edit it — or <a href="<%= agent_path(@agent.id) %>/prompts/new#prompts">create a new one</a>.</p>
532
+ </div>
140
533
  </div>
141
- <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Saving…">Save prompt</button></div>
142
- </form>
143
- <% unless pf[:versions].empty? %>
144
- <details class="versions">
145
- <summary><%= pf[:versions].size %> previous version(s)</summary>
146
- <ul>
147
- <% pf[:versions].each_with_index do |v, i| %>
148
- <li>
149
- <span class="muted mono"><%= v["at"] %></span>
150
- <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts/restore">
151
- <%== csrf_tag %>
152
- <input type="hidden" name="file" value="<%= pf[:name] %>">
153
- <input type="hidden" name="version" value="<%= i %>">
154
- <button type="submit" class="ghost">Restore</button>
155
- </form>
156
- </li>
534
+ <% else %>
535
+ <% new = @prompt_selected == "" %>
536
+ <% pf = new ? nil : @prompt_files.find { |f| f[:name] == @prompt_selected } %>
537
+ <div class="drill-pane-head">
538
+ <div class="title">
539
+ <strong class="mono"><%= new ? "New prompt file" : @prompt_selected %></strong>
540
+ </div>
541
+ <button type="submit" form="prompt-form" class="btn primary" data-turbo-submits-with="Saving…"><%= new ? "Create prompt" : "Save prompt" %></button>
542
+ </div>
543
+ <div class="drill-pane-body">
544
+ <form id="prompt-form" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts" data-controller="dirty-guard">
545
+ <%== csrf_tag %>
546
+ <% if new %>
547
+ <label>file name <span class="muted">(e.g. <code>IDENTITY.md</code>)</span>
548
+ <input type="text" name="file" placeholder="IDENTITY.md" required>
549
+ </label>
550
+ <% else %>
551
+ <input type="hidden" name="file" value="<%= @prompt_selected %>">
157
552
  <% end %>
158
- </ul>
159
- </details>
160
- <% end %>
161
- </div>
162
- <% end %>
553
+ <label>content <span class="muted">(markdown — the agent's identity/voice)</span></label>
554
+ <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
555
+ <textarea name="content" data-code-editor-target="source" rows="18"><%= new ? "" : (pf ? pf[:content] : "") %></textarea>
556
+ </div>
557
+ </form>
163
558
 
164
- <details class="card newfile">
165
- <summary>+ new prompt file</summary>
166
- <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts" data-controller="dirty-guard">
167
- <%== csrf_tag %>
168
- <label>file name
169
- <input type="text" name="file" placeholder="IDENTITY.md" required>
170
- </label>
171
- <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
172
- <textarea name="content" data-code-editor-target="source" rows="8" placeholder="# Identity&#10;You are..."></textarea>
173
- </div>
174
- <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Creating…">Create prompt</button></div>
175
- </form>
176
- </details>
559
+ <% if !new && pf %>
560
+ <% unless pf[:versions].empty? %>
561
+ <div class="drill-section">
562
+ <div class="drill-section-head">
563
+ <h3>Versions</h3>
564
+ <span class="muted">store-backed history restore replaces the current content.</span>
565
+ </div>
566
+ <ul class="versions-list">
567
+ <% pf[:versions].each_with_index do |v, i| %>
568
+ <li>
569
+ <span class="muted mono"><%= v["at"] %></span>
570
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts/restore">
571
+ <%== csrf_tag %>
572
+ <input type="hidden" name="file" value="<%= pf[:name] %>">
573
+ <input type="hidden" name="version" value="<%= i %>">
574
+ <button type="submit" class="ghost">Restore</button>
575
+ </form>
576
+ </li>
577
+ <% end %>
578
+ </ul>
579
+ </div>
580
+ <% end %>
581
+
582
+ <div class="drill-section">
583
+ <div class="drill-section-head">
584
+ <h3>Remove</h3>
585
+ <span class="muted">deletes the file and drops it from <code>prompt_files</code> — the agent falls back to the deployment default.</span>
586
+ </div>
587
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/prompts/delete" data-turbo-confirm="Remove prompt file &quot;<%= pf[:name] %>&quot;? This can't be undone.">
588
+ <%== csrf_tag %>
589
+ <input type="hidden" name="file" value="<%= pf[:name] %>">
590
+ <div class="actions"><button type="submit" class="danger" data-turbo-submits-with="Removing…">Remove this file</button></div>
591
+ </form>
592
+ </div>
593
+ <% end %>
594
+ </div>
595
+ <% end %>
596
+ </section>
597
+ </div>
177
598
  </section>
178
599
 
179
600
  <!-- ── Agent skills ──────────────────────────────────────────────────────── -->
180
- <section class="section" id="skills">
601
+ <section class="section" id="skills" data-tabs-target="panel">
181
602
  <div class="section-head">
182
603
  <h2>Skills</h2>
183
604
  <p class="muted">which catalog skills this agent may load (<code>load_skill</code>).</p>
184
605
  </div>
185
- <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/skills">
606
+ <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/skills" data-controller="toggle-counter">
186
607
  <%== csrf_tag %>
187
- <label class="check">
188
- <input type="checkbox" name="all_skills" value="1"<%== " checked" if @agent_skills.nil? %>>
189
- all skills (open allowlist)
190
- </label>
191
- <% if @all_skills.empty? %>
192
- <p class="muted">No skills in the catalog. Create one under <a href="/studio/skills/new">skills → new</a>.</p>
193
- <% else %>
194
- <div class="check-grid">
608
+ <div class="matrix-topline">
609
+ <% if @all_skills.empty? %>
610
+ <span class="muted">No skills in the catalog yet — create one under <a href="/studio/skills/new">skills → new</a>.</span>
611
+ <% else %>
612
+ <span class="count-badge mono" data-toggle-counter-target="count"><%= @agent_skills.nil? ? "all" : "#{@agent_skills.size}/#{@all_skills.size} on" %></span>
613
+ <label class="switch" title="open allowlist every catalog skill flows through">
614
+ <input type="checkbox" name="all_skills" value="1"<%== " checked" if @agent_skills.nil? %>
615
+ data-toggle-counter-target="all" data-action="change->toggle-counter#update">
616
+ <span class="switch-track" aria-hidden="true"></span>
617
+ <span class="switch-label">all skills</span>
618
+ </label>
619
+ <% end %>
620
+ </div>
621
+ <% unless @all_skills.empty? %>
622
+ <div class="check-grid tool-grid" data-toggle-counter-target="grid">
195
623
  <% @all_skills.sort_by(&:name).each do |s| %>
196
- <label class="check">
197
- <input type="checkbox" name="skills[]" value="<%= s.name %>"<%== " checked" if @agent_skills&.include?(s.name) %>>
624
+ <label class="check tool-check">
625
+ <input type="checkbox" name="skills[]" value="<%= s.name %>"<%== " checked" if @agent_skills&.include?(s.name) %>
626
+ data-toggle-counter-target="tool" data-locked="false" data-action="change->toggle-counter#update">
198
627
  <span class="mono"><%= s.name %></span>
199
628
  </label>
200
629
  <% end %>
@@ -208,10 +637,10 @@
208
637
  </section>
209
638
 
210
639
  <!-- ── Agent memory (tenant = id) ────────────────────────────────────────── -->
211
- <section class="section" id="memory">
640
+ <section class="section" id="memory" data-tabs-target="panel">
212
641
  <div class="section-head">
213
642
  <h2>Memory</h2>
214
- <p class="muted">facts and notes the BIA reads each turn (tenant <code><%= @agent.id %></code>).</p>
643
+ <p class="muted">facts and notes the agent reads each turn (tenant <code><%= @agent.id %></code>).</p>
215
644
  </div>
216
645
  <div class="card">
217
646
  <h3>Facts</h3>
@@ -259,8 +688,121 @@
259
688
  </div>
260
689
  </section>
261
690
 
691
+ <!-- ── Automatic loops ─────────────────────────────────────────────────────── -->
692
+ <section class="section" id="loops" data-tabs-target="panel">
693
+ <div class="section-head">
694
+ <h2>Automatic loops</h2>
695
+ <p class="muted">each loop runs on its own worker fiber when this agent declares it — no cron, no restart. The buttons ask for a pass NOW, without waiting for the window; the loops that need a human (facts, skills, edits) always stop at the gate.</p>
696
+ </div>
697
+
698
+ <% distill_on = Insika::Coercion.truthy?((@agent.distill || {})["enabled"]) %>
699
+ <% harvest_on = Insika::Coercion.truthy?((@agent.harvest || {})["enabled"]) %>
700
+ <div class="card">
701
+ <div class="section-head">
702
+ <h3>Distillation <span class="muted">— idle conversations → fact proposals</span></h3>
703
+ <span class="pill <%= distill_on ? 'ok' : 'info' %>"><%= distill_on ? "active · idle #{(@agent.distill["idle_hours"] || 6)}h" : "off" %></span>
704
+ </div>
705
+ <p class="muted">Proposals land on the <a href="/studio/facts">Facts page</a> — nothing is ever applied without your click. Runs automatically on the engine's window.</p>
706
+ <% if distill_on && insika[:command_bus].registered?(:run_distillation) %>
707
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/distill" class="actions">
708
+ <%== csrf_tag %>
709
+ <button type="submit" class="primary" data-turbo-submits-with="Distilling…">Run distillation now</button>
710
+ <span class="muted">the due sessions (idle, long enough, undistilled)</span>
711
+ </form>
712
+ <% end %>
713
+ </div>
714
+
715
+ <div class="card">
716
+ <div class="section-head">
717
+ <h3>Harvest <span class="muted">— idle traffic → skill candidates</span></h3>
718
+ <span class="pill <%= harvest_on ? 'ok' : 'info' %>"><%= harvest_on ? "active · idle #{(@agent.harvest["idle_hours"] || 24)}h" : "off" %></span>
719
+ </div>
720
+ <p class="muted">Candidates are double-gated and promoted by a human on the <a href="/studio/harvest?agent=<%= Rack::Utils.escape(@agent.id) %>">Harvest page</a>. Runs automatically on the engine's window.</p>
721
+ <% if harvest_on && insika[:command_bus].registered?(:run_harvest) %>
722
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/harvest" class="actions">
723
+ <%== csrf_tag %>
724
+ <button type="submit" class="primary" data-turbo-submits-with="Mining…">Run harvest now</button>
725
+ <span class="muted">mines one window of this agent's finished traffic</span>
726
+ </form>
727
+ <% end %>
728
+ </div>
729
+
730
+ <div class="card">
731
+ <div class="section-head">
732
+ <h3>Refinement <span class="muted">— traffic → ranked findings</span></h3>
733
+ <span class="pill info"><%= @agent.refinement ? "configured · #{(@agent.refinement["mode"] || "report")}" : "report-only" %></span>
734
+ </div>
735
+ <p class="muted">A run reads this agent's own traffic and records what broke, ranked. It never edits the agent unless <span class="mono">auto_apply</span> is declared. Results on the <a href="/studio/refinement?agent=<%= Rack::Utils.escape(@agent.id) %>">Refinement page</a>.</p>
736
+ <% if insika[:command_bus].registered?(:run_refinement) %>
737
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/refinement" class="actions">
738
+ <%== csrf_tag %>
739
+ <button type="submit" class="primary" data-turbo-submits-with="Reading traffic…">Run refinement now</button>
740
+ <span class="muted">no model call — the collector reads stores only</span>
741
+ </form>
742
+ <% end %>
743
+ </div>
744
+ </section>
745
+
746
+ <!-- ── Outcomes (WS7) ────────────────────────────────────────────────────── -->
747
+ <section class="section" id="outcomes" data-tabs-target="panel">
748
+ <div class="section-head">
749
+ <h2>Outcomes</h2>
750
+ <p class="muted">business results recorded via <code>POST /v1/outcomes</code> — last outcome and per-day series. The engine transports them; it does not interpret them.</p>
751
+ </div>
752
+ <div class="card">
753
+ <% if @latest_outcome %>
754
+ <p>last
755
+ <span class="pill <%= @latest_outcome[:outcome] == "conversion" ? "ok" : "warn" %>"><%= @latest_outcome[:outcome] %><%== format(" · R$ %0.2f", @latest_outcome[:value]) if @latest_outcome[:value] && @latest_outcome[:value].positive? %></span>
756
+ <span class="muted mono"><%= @latest_outcome[:at] %></span>
757
+ </p>
758
+ <% end %>
759
+ <% if @outcome_series.nil? || @outcome_series.empty? %>
760
+ <p class="muted">No outcomes recorded.</p>
761
+ <% else %>
762
+ <ul class="kv">
763
+ <% @outcome_series.sort.reverse.each do |day, by_outcome| %>
764
+ <li>
765
+ <span class="k mono"><%= day %></span>
766
+ <span class="v"><%= by_outcome.map { |name, cell|
767
+ bits = ["#{name} ×#{cell[:count]}"]
768
+ bits << format("R$ %0.2f", cell[:value]) if cell[:value].to_f.positive?
769
+ bits.join(" · ")
770
+ }.join(" · ") %></span>
771
+ </li>
772
+ <% end %>
773
+ </ul>
774
+ <% end %>
775
+ </div>
776
+ </section>
777
+
778
+ <!-- ── Cache ─────────────────────────────────────────────────── -->
779
+ <section class="section" id="cache" data-tabs-target="panel">
780
+ <div class="section-head">
781
+ <h2>Cache</h2>
782
+ <p class="muted">prefix cache-hit per turn (provider-reported) — the invalidation reason names the first block whose bytes changed vs the previous turn.</p>
783
+ </div>
784
+ <div class="card">
785
+ <% if @cache_series.empty? %>
786
+ <p class="muted">No turns recorded.</p>
787
+ <% else %>
788
+ <ul class="kv">
789
+ <% @cache_series.last(20).reverse.each do |c| %>
790
+ <li>
791
+ <span class="k mono"><%= c["at"] %></span>
792
+ <span class="v">
793
+ <%== c["hit_pct"].nil? ? "—" : "#{c['hit_pct']}% cached" %>
794
+ · <%= c["cached_tokens"] %>/<%= c["prompt_tokens"] %> tokens
795
+ <% if c["invalidation_reason"] %><span class="lat">broke: <%= c["invalidation_reason"] %></span><% end %>
796
+ </span>
797
+ </li>
798
+ <% end %>
799
+ </ul>
800
+ <% end %>
801
+ </div>
802
+ </section>
803
+
262
804
  <!-- ── History ───────────────────────────────────────────────────────────── -->
263
- <section class="section" id="history">
805
+ <section class="section" id="history" data-tabs-target="panel">
264
806
  <div class="section-head">
265
807
  <h2>History</h2>
266
808
  <p class="muted">recent conversations (all agents — a session doesn't stamp its author).</p>
@@ -283,3 +825,4 @@
283
825
  </div>
284
826
  <% end %>
285
827
  </section>
828
+ </div>