insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -2,9 +2,9 @@
2
2
  <section class="section" id="skills" data-tabs-target="panel">
3
3
  <div class="section-head">
4
4
  <h2>Skills</h2>
5
- <p class="muted">which catalog skills this agent may load (<code>load_skill</code>).</p>
5
+ <p class="muted">Choose which catalog skills this agent may load. Save skills applies this selection to <strong><%= @agent.id %></strong>.</p>
6
6
  </div>
7
- <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/skills" data-controller="toggle-counter">
7
+ <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/skills" data-controller="toggle-counter dirty-guard">
8
8
  <%== csrf_tag %>
9
9
  <div class="matrix-topline">
10
10
  <% if @all_skills.empty? %>
@@ -19,20 +19,29 @@
19
19
  </label>
20
20
  <% end %>
21
21
  </div>
22
+ <% kit_skills = @agent_skills.nil? ? {} : kit_sources(@agent, "skills") %>
23
+ <% if Array(@agent.kits).any? %>
24
+ <% kit_links = @agent.kits.map { |k| %(<a class="pill info" href="/studio/kits/#{Rack::Utils.escape(k)}" data-turbo-frame="_top">#{h(k)}</a>) }.join(" ") %>
25
+ <% if @agent_skills.nil? %>
26
+ <p class="muted">Its kits <%== kit_links %> add nothing here: this agent already gets every skill. Turn off <strong>all skills</strong> to limit it to its own list plus its kits.</p>
27
+ <% else %>
28
+ <p class="muted">Skills marked <span class="pill info">kit</span> come from its kits <%== kit_links %> and are edited there.</p>
29
+ <% end %>
30
+ <% end %>
22
31
  <% unless @all_skills.empty? %>
23
32
  <div class="check-grid tool-grid" data-toggle-counter-target="grid">
24
33
  <% @all_skills.sort_by(&:name).each do |s| %>
25
- <label class="check tool-check">
26
- <input type="checkbox" name="skills[]" value="<%= s.name %>"<%== " checked" if @agent_skills&.include?(s.name) %>
27
- data-toggle-counter-target="tool" data-locked="false" data-action="change->toggle-counter#update">
28
- <span class="mono"><%= s.name %></span>
34
+ <% from_kit = !@agent_skills.nil? && !@agent_skills.include?(s.name) && kit_skills[s.name] %>
35
+ <label class="check tool-check"<%== %( title="from kit #{h(from_kit)}") if from_kit %>>
36
+ <input type="checkbox" name="skills[]" value="<%= s.name %>"<%== " checked" if @agent_skills&.include?(s.name) || from_kit %><%== " disabled" if from_kit %>
37
+ data-toggle-counter-target="tool" data-locked="<%= !!from_kit %>" data-action="change->toggle-counter#update">
38
+ <span class="mono"><%= s.name %></span><% if from_kit %> <span class="pill info">kit</span><% end %>
29
39
  </label>
30
40
  <% end %>
31
41
  </div>
32
42
  <% end %>
33
43
  <div class="actions">
34
44
  <button type="submit" class="primary" data-turbo-submits-with="Saving…">Save skills</button>
35
- <a class="btn ghost" href="/studio/tools" data-turbo-frame="_top">Edit tools →</a>
36
45
  </div>
37
46
  </form>
38
47
  </section>
@@ -0,0 +1,90 @@
1
+ <!-- ── Agent tools ───────────────────────────────────────────────────────── -->
2
+ <%# The agent's tool access. It reads the agent's OWN record (@agent_own): kit
3
+ tools render on and locked, never posted, so a save never copies them in. %>
4
+ <% a = @agent_own %>
5
+ <section class="section" id="tools" data-tabs-target="panel">
6
+ <div class="section-head">
7
+ <h2>Tools</h2>
8
+ <p class="muted">Choose which tools this agent may call. Save tools applies this selection to <strong><%= a.id %></strong> on its next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins.</p>
9
+ </div>
10
+ <div class="card only-on-scope" data-controller="toggle-counter list-filter">
11
+ <div class="matrix-topline">
12
+ <span class="count-badge mono" data-toggle-counter-target="count"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size} on" %></span>
13
+ <label class="switch" title="open allowlist — every tool flows through">
14
+ <input type="checkbox" name="all_tools" value="1" form="tools-form"<%== " checked" if a.tools_allow.nil? %>
15
+ data-toggle-counter-target="all" data-action="change->toggle-counter#update">
16
+ <span class="switch-track" aria-hidden="true"></span>
17
+ <span class="switch-label">all tools</span>
18
+ </label>
19
+ <button type="button" class="btn ghost btn-sm" data-action="click->toggle-counter#selectNone"
20
+ title="uncheck every tool (deny-all — Save tools to apply)">none</button>
21
+ <label class="switch" title="hide tools this agent cannot call; typing a search shows every match">
22
+ <input type="checkbox" id="only-on" checked>
23
+ <span class="switch-track" aria-hidden="true"></span>
24
+ <span class="switch-label">only enabled</span>
25
+ </label>
26
+ </div>
27
+ <% open_all = a.tools_allow.nil? && a.tools_allow_groups.nil? %>
28
+ <% kit_tools = open_all ? {} : kit_sources(a, "tools") %>
29
+ <% server_groups = open_all ? [] : Array(a.tools_allow_groups).map(&:to_s) %>
30
+ <% if Array(a.kits).any? %>
31
+ <% kit_links = a.kits.map { |k| %(<a class="pill info" href="/studio/kits/#{Rack::Utils.escape(k)}" data-turbo-frame="_top">#{h(k)}</a>) }.join(" ") %>
32
+ <% if open_all %>
33
+ <p class="muted">Its kits <%== kit_links %> add nothing here: this agent already gets every tool. Turn off <strong>all tools</strong> to limit it to its own list plus its kits.</p>
34
+ <% else %>
35
+ <p class="muted">Tools marked <span class="pill info">kit</span> come from its kits <%== kit_links %> and are edited there.</p>
36
+ <% end %>
37
+ <% end %>
38
+ <input type="search" class="filter-input" placeholder="Search to add tools…" aria-label="Search tools or servers"
39
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
40
+ <p class="empty" data-list-filter-target="empty" hidden>No tools match your filter.</p>
41
+ <form id="tools-form" method="post" action="/studio/agents/<%= Rack::Utils.escape(a.id) %>/tools" data-controller="dirty-guard">
42
+ <%== csrf_tag %>
43
+ <% if @tools.empty? %>
44
+ <p class="muted">No tools registered in the runtime.</p>
45
+ <% else %>
46
+ <div class="tool-grid<%== " is-open" if a.tools_allow.nil? %>" data-toggle-counter-target="grid">
47
+ <% @tool_groups.each do |label, tools| %>
48
+ <details class="tool-section" aria-label="<%= label %>" data-list-filter-target="group" open>
49
+ <% group = tools.first.plugin.to_s if label.start_with?("MCP") %>
50
+ <summary><%= label %> <span class="count"><%= tools.count { |t| !tool_denied_for?(a, t.name) && (tool_allowed_for?(a, t.name) || kit_tools[t.name] || server_groups.include?(t.plugin.to_s)) } %>/<%= tools.size %></span></summary>
51
+ <% if group %>
52
+ <input type="hidden" name="managed_groups[]" value="<%= group %>">
53
+ <label class="switch" title="every tool this server exposes, now and later">
54
+ <input type="checkbox" name="tool_groups[]" value="<%= group %>"<%== " checked" if server_groups.include?(group) %>>
55
+ <span class="switch-track" aria-hidden="true"></span>
56
+ <span class="switch-label">whole server</span>
57
+ </label>
58
+ <% end %>
59
+ <div class="matrix-topline">
60
+ <button type="button" class="btn ghost btn-sm" data-action="click->toggle-counter#selectSection" data-checked="true"
61
+ aria-label="Select all tools in <%= label %>">Select all</button>
62
+ <button type="button" class="btn ghost btn-sm" data-action="click->toggle-counter#selectSection" data-checked="false"
63
+ aria-label="Deselect all tools in <%= label %>">Deselect all</button>
64
+ </div>
65
+ <div class="check-grid tool-grid">
66
+ <% tools.each do |t| %>
67
+ <% denied = tool_denied_for?(a, t.name) %>
68
+ <% from_kit = !denied && !tool_allowed_for?(a, t.name) && (kit_tools[t.name] || (server_groups.include?(t.plugin.to_s) && "server")) %>
69
+ <label class="check tool-check<%== " locked" if denied %>" title="<%= denied ? "denied — deny wins" : from_kit == "server" ? "the whole server is on" : from_kit ? "from kit #{from_kit}" : t.description %>"
70
+ data-list-filter-target="item" data-filter-text="<%= "#{label} #{t.name}" %>">
71
+ <input type="checkbox" name="tools[]" value="<%= t.name %>"
72
+ <%== " checked" if (tool_allowed_for?(a, t.name) && !denied) || from_kit %>
73
+ <%== " disabled" if denied || from_kit %>
74
+ data-toggle-counter-target="tool" data-locked="<%= denied || !!from_kit %>"
75
+ data-action="change->toggle-counter#update">
76
+ <span class="mono"><%= t.name %></span><% if @data_tool_names.include?(t.name) %> <span class="pill plain">data</span><% end %><% if denied %> <span class="pill locked">locked</span><% end %><% if from_kit %> <span class="pill info"><%= from_kit == "server" ? "server" : "kit" %></span><% end %>
77
+ </label>
78
+ <% end %>
79
+ </div>
80
+ </details>
81
+ <% end %>
82
+ </div>
83
+ <% end %>
84
+ <div class="actions">
85
+ <button type="submit" class="primary" data-turbo-submits-with="Saving…">Save tools</button>
86
+ <a class="btn ghost" href="/studio/tools" data-turbo-frame="_top">Tool catalog</a>
87
+ </div>
88
+ </form>
89
+ </div>
90
+ </section>
@@ -2,9 +2,9 @@
2
2
  index (agents.erb) and the detail (agent_detail.erb). Rows target the
3
3
  agent-detail turbo-frame with data-turbo-action="advance": the detail
4
4
  loads beside the list while the URL moves, so refresh/deep-link render the
5
- same screen. Locals: active_id lights the current row (nil on the index);
6
- show_outcomes renders the last-outcome pills (index only — the detail's
7
- outcomes tab is THAT agent's series, so foreign pills would blur it). %>
5
+ same screen. Locals: active_id lights the current row (nil on the index).
6
+ The last outcome lives on the agent's Outcomes tab, not here: in the list
7
+ it read like a kit or a warning. %>
8
8
  <aside class="drill-pane drill-master">
9
9
  <div class="drill-pane-head">
10
10
  <div class="title"><strong>Agents <span class="count"><%= @agents.size %></span></strong></div>
@@ -16,26 +16,22 @@
16
16
  </div>
17
17
  <nav class="drill-pane-body drill-list" aria-label="agents" data-controller="active-link">
18
18
  <% active_id = defined?(active_id) ? active_id : nil %>
19
- <% show_outcomes = defined?(show_outcomes) ? show_outcomes : false %>
20
19
  <% if @agents.empty? %>
21
- <div class="empty">No agents yet. Create your first agent from the pane on the right.</div>
20
+ <div class="empty">No agents yet. Create your first agent using the new agent form.</div>
22
21
  <% else %>
23
22
  <% @agents.each do |a| %>
24
23
  <a class="drill-item<%== ' active' if a.id == active_id %>"
25
24
  href="/studio/agents/<%= Rack::Utils.escape(a.id) %>"
26
25
  data-turbo-frame="agent-detail" data-turbo-action="advance"
27
- data-list-filter-target="item" data-filter-text="<%= a.id %> <%= a.model %> <%= a.provider %>"
26
+ data-list-filter-target="item" data-filter-text="<%= a.id %> <%= a.model %> <%= a.provider %> <%= Array(a.kits).join(" ") %>"
28
27
  data-active-link-target="item" data-action="click->active-link#select">
29
28
  <div class="drill-item-name">
30
29
  <span class="avatar sm <%= avatar_class(a.id) %>"><%= a.id[0, 2].upcase %></span>
31
30
  <span class="txt" title="<%= a.id %>"><%= a.id %></span>
32
- <% if a.memory %><span class="pill plain">mem</span><% end %>
33
- <% if show_outcomes && (latest = @latest_outcomes&.dig(a.id)) %>
34
- <span class="pill <%= latest[:outcome] == "conversion" ? "ok" : "warn" %>"
35
- title="last outcome recorded via /v1/outcomes"><%= latest[:outcome] %><%== format(" · R$ %0.2f", latest[:value]) if latest[:value] && latest[:value].positive? %></span>
36
- <% end %>
31
+ <% Array(a.kits).each do |k| %><span class="pill info"><%= k %></span><% end %>
32
+ <% if a.memory %><span class="pill plain">memory</span><% end %>
37
33
  </div>
38
- <div class="drill-item-sub"><%= a.model %><%== " · #{h(a.provider)}" if a.provider %></div>
34
+ <div class="drill-item-sub"><%= a.model %> · <span<%== ' class="warn-text" title="gets every tool or skill — limit it in its Tools or Skills tab"' if agent_open?(a) %>><%= agent_reach(a) %></span></div>
39
35
  </a>
40
36
  <% end %>
41
37
  <div class="empty" data-list-filter-target="empty" hidden>No agents match your filter.</div>
@@ -0,0 +1,4 @@
1
+ <%== inherit_toggle("alerts") %>
2
+ <label>alerts webhook <span class="muted">(budget_warning / breaker_open / delivery_failed)</span>
3
+ <input type="text" name="alerts_webhook" value="<%= @agent.alerts && @agent.alerts["webhook"] %>" placeholder="https://ops.example.com/insika-alerts">
4
+ </label>
@@ -0,0 +1,20 @@
1
+ <fieldset class="subgroup">
2
+ <legend>budget <span class="muted">— WS2: calendar spend caps per (tenant, agent)</span></legend>
3
+ <%== inherit_toggle("budget") %>
4
+ <% bd = @agent.budget || {} %>
5
+ <div class="grid-3">
6
+ <label>daily (tokens)
7
+ <input type="text" name="budget_daily" value="<%= bd["daily"] %>" inputmode="numeric">
8
+ </label>
9
+ <label>monthly (tokens)
10
+ <input type="text" name="budget_monthly" value="<%= bd["monthly"] %>" inputmode="numeric">
11
+ </label>
12
+ <label>alert at <span class="muted">(0–1 of the cap)</span>
13
+ <input type="text" name="budget_alert_at" value="<%= bd["alert_at"] %>" placeholder="0.8" inputmode="decimal">
14
+ </label>
15
+ </div>
16
+ <label class="check">
17
+ <input type="checkbox" name="budget_soft" value="1"<%== " checked" if bd["soft"] %>>
18
+ soft cap <span class="muted">— crossing it warns and runs; hard (default) fails the turn</span>
19
+ </label>
20
+ </fieldset>
@@ -0,0 +1,26 @@
1
+ <fieldset class="subgroup">
2
+ <legend>distillation <span class="muted">— finished conversations → proposed customer facts (Facts page)</span></legend>
3
+ <%== inherit_toggle("distill") %>
4
+ <% dist = @agent.distill || {} %>
5
+ <label class="check">
6
+ <input type="checkbox" name="distill_enabled" value="1"<%== " checked" if Insika::Coercion.truthy?(dist["enabled"]) %>>
7
+ distillation on — idle sessions are read back as fact proposals; a human always approves
8
+ </label>
9
+ <div class="grid-3">
10
+ <label>idle hours <span class="muted">(blank = 6)</span>
11
+ <input type="text" name="distill_idle_hours" value="<%= dist["idle_hours"] %>" placeholder="6" inputmode="numeric">
12
+ </label>
13
+ <label>min messages <span class="muted">(blank = 3)</span>
14
+ <input type="text" name="distill_min_messages" value="<%= dist["min_messages"] %>" placeholder="3" inputmode="numeric">
15
+ </label>
16
+ <label>max proposals <span class="muted">(blank = 10)</span>
17
+ <input type="text" name="distill_max_proposals" value="<%= dist["max_proposals"] %>" placeholder="10" inputmode="numeric">
18
+ </label>
19
+ </div>
20
+ <label>model <span class="muted">(blank = the platform utility_model)</span>
21
+ <%== model_select("distill_model_ref", dist["model"], "platform utility_model") %>
22
+ </label>
23
+ <label>prompt <span class="muted">— what counts as a fact for THIS store (blank = the engine default)</span>
24
+ <textarea name="distill_prompt" rows="3" placeholder="what counts as a customer fact for this store"><%= dist["prompt"] %></textarea>
25
+ </label>
26
+ </fieldset>
@@ -0,0 +1,30 @@
1
+ <fieldset class="subgroup">
2
+ <legend>follow-ups <span class="muted">— the seller who comes back (blank arm = off)</span></legend>
3
+ <%== inherit_toggle("followup") %>
4
+ <% fu = @agent.followup || {}; fup = fu["policy"] || {}; fqh = fup["quiet_hours"] || {} %>
5
+ <div class="grid-2">
6
+ <label>arm
7
+ <input type="text" name="followup_arm" value="<%= fu["arm"] %>" placeholder="schedule">
8
+ </label>
9
+ <label>max frequency <span class="muted">(e.g. <code>2/24h</code>)</span>
10
+ <input type="text" name="followup_max_frequency" value="<%= fup["max_frequency"] %>" placeholder="2/24h">
11
+ </label>
12
+ <label>quiet hours timezone <span class="muted">(blank = no quiet hours)</span>
13
+ <input type="text" name="followup_tz" value="<%= fqh["timezone"] %>" placeholder="America/Sao_Paulo">
14
+ </label>
15
+ <div class="grid-2">
16
+ <label>quiet start
17
+ <input type="text" name="followup_quiet_start" value="<%= fqh["start"] %>" placeholder="21:30">
18
+ </label>
19
+ <label>quiet end
20
+ <input type="text" name="followup_quiet_end" value="<%= fqh["end"] %>" placeholder="09:00">
21
+ </label>
22
+ </div>
23
+ <label>cancel keywords <span class="muted">(comma-separated)</span>
24
+ <input type="text" name="followup_cancel_keywords" value="<%= agent_list(fup["cancel_keywords"]) %>" placeholder="não quero mais contato">
25
+ </label>
26
+ <label>silence after N sends
27
+ <input type="text" name="followup_silence_after_sends" value="<%= fup["silence_after_sends"] %>" placeholder="3" inputmode="numeric">
28
+ </label>
29
+ </div>
30
+ </fieldset>
@@ -0,0 +1,21 @@
1
+ <fieldset class="subgroup">
2
+ <legend>grounding <span class="muted">— product claims must point at evidence the session actually saw</span></legend>
3
+ <%== inherit_toggle("grounding") %>
4
+ <% gr = @agent.grounding || {} %>
5
+ <div class="grid-2">
6
+ <label>mode <span class="muted">(blank = off)</span>
7
+ <select name="grounding_mode">
8
+ <option value="">off</option>
9
+ <% %w[flag enforce].each do |m| %>
10
+ <option value="<%= m %>"<%== ' selected' if gr["mode"] == m %>><%= m %></option>
11
+ <% end %>
12
+ </select>
13
+ </label>
14
+ <label>matcher SKU <span class="muted">(evidence id prefix, e.g. <code>sku:</code>)</span>
15
+ <input type="text" name="grounding_matcher_sku" value="<%= gr.dig("matcher", "sku") %>" placeholder="sku:">
16
+ </label>
17
+ <label>name keys <span class="muted">(comma-separated metadata keys with product names)</span>
18
+ <input type="text" name="grounding_name_keys" value="<%= agent_list(gr.dig("matcher", "name_keys")) %>" placeholder="product, name">
19
+ </label>
20
+ </div>
21
+ </fieldset>
@@ -0,0 +1,47 @@
1
+ <fieldset class="subgroup">
2
+ <legend>guardrails <span class="muted">— content safety</span></legend>
3
+ <%== inherit_toggle("guardrails") %>
4
+ <label class="check">
5
+ <input type="checkbox" name="guardrail_input" value="1"<%== " checked" if guardrail_field(@guardrails, :input, true) %>>
6
+ input guardrail <span class="muted">— check injection / abuse / inappropriate before the LLM</span>
7
+ </label>
8
+ <label class="check">
9
+ <input type="checkbox" name="guardrail_output" value="1"<%== " checked" if guardrail_field(@guardrails, :output, true) %>>
10
+ output guardrail <span class="muted">— redact PII/secret in the stream + flag policy slips</span>
11
+ </label>
12
+ <div class="grid-2">
13
+ <label>strictness
14
+ <% strictness = guardrail_field(@guardrails, :strictness, "medium").to_s %>
15
+ <select name="guardrail_strictness">
16
+ <% %w[low medium high].each do |lvl| %>
17
+ <option value="<%= lvl %>"<%== " selected" if lvl == strictness %>><%= lvl %></option>
18
+ <% end %>
19
+ </select>
20
+ </label>
21
+ <label>on injection <span class="muted">(flag = the model answers with a notice; block = safe reply, the model never sees it)</span>
22
+ <% injection = guardrail_field(@guardrails, :injection, "flag").to_s %>
23
+ <select name="guardrail_injection">
24
+ <% %w[flag block].each do |mode| %>
25
+ <option value="<%= mode %>"<%== " selected" if mode == injection %>><%= mode %></option>
26
+ <% end %>
27
+ </select>
28
+ </label>
29
+ <label>LLM moderator <span class="muted">(model ref, or <code>on</code> for the platform utility_model; <code>typesafe/jev-latest</code> = fast judge, unsure cases go to the utility_model; blank = deterministic only)</span>
30
+ <input type="text" name="guardrail_moderator" value="<%= guardrail_field(@guardrails, :moderator, "") %>" placeholder="deepseek/deepseek-v4-flash">
31
+ </label>
32
+ </div>
33
+ <details class="newfile">
34
+ <summary>safe replies — override per category <span class="muted">(blank = engine default; this agent's voice/language)</span></summary>
35
+ <% gr_resp = (@guardrails["responses"] || @guardrails[:responses] || {}) %>
36
+ <label>default <span class="muted">(catch-all for any blocked category)</span>
37
+ <textarea name="guardrail_response_default" rows="2" placeholder="I can't help with that particular request…"><%= guardrail_field(gr_resp, :default, "") %></textarea>
38
+ </label>
39
+ <div class="grid-2">
40
+ <% %w[injection sexual abuse escalate].each do |cat| %>
41
+ <label><%= cat %>
42
+ <textarea name="guardrail_response_<%= cat %>" rows="2"><%= guardrail_field(gr_resp, cat, "") %></textarea>
43
+ </label>
44
+ <% end %>
45
+ </div>
46
+ </details>
47
+ </fieldset>
@@ -0,0 +1,35 @@
1
+ <fieldset class="subgroup">
2
+ <legend>harvest <span class="muted">— finished traffic → proposed SKILLs, double-gated, human-promoted (Harvest page)</span></legend>
3
+ <%== inherit_toggle("harvest") %>
4
+ <% hv = @agent.harvest || {}; hvm = hv["miner"] || {} %>
5
+ <label class="check">
6
+ <input type="checkbox" name="harvest_enabled" value="1"<%== " checked" if Insika::Coercion.truthy?(hv["enabled"]) %>>
7
+ harvest on — idle conversations are mined for skills; a human promotes every one
8
+ </label>
9
+ <div class="grid-3">
10
+ <label>idle hours <span class="muted">(blank = 24)</span>
11
+ <input type="text" name="harvest_idle_hours" value="<%= hv["idle_hours"] %>" placeholder="24" inputmode="numeric">
12
+ </label>
13
+ <label>min messages <span class="muted">(blank = 3)</span>
14
+ <input type="text" name="harvest_min_messages" value="<%= hv["min_messages"] %>" placeholder="3" inputmode="numeric">
15
+ </label>
16
+ <label>miner model <span class="muted">(blank = the platform utility_model)</span>
17
+ <%== model_select("harvest_miner_model_ref", hvm["model"], "platform utility_model") %>
18
+ </label>
19
+ <label>miner window <span class="muted">(last N conversations)</span>
20
+ <input type="text" name="harvest_miner_window" value="<%= hvm.dig("window", "last_sessions") %>" placeholder="200" inputmode="numeric">
21
+ </label>
22
+ <label>miner max proposals
23
+ <input type="text" name="harvest_miner_max_proposals" value="<%= hvm["max_proposals"] %>" placeholder="10" inputmode="numeric">
24
+ </label>
25
+ <label>miner budget (tokens)
26
+ <input type="text" name="harvest_miner_budget_tokens" value="<%= hvm.dig("budget", "tokens") %>" placeholder="20000" inputmode="numeric">
27
+ </label>
28
+ </div>
29
+ <label>negative list <span class="muted">— JSON: <code>[{ "rule": "…", "pattern": "…" }]</code> (blank = the deployment seed)</span>
30
+ <textarea name="harvest_negative_list" rows="3" spellcheck="false" class="mono" placeholder='[ { "rule": "no-competitor-prices", "pattern": "concorrente" } ]'><%= agent_json(hv["negative_list"]) %></textarea>
31
+ </label>
32
+ <label>prompt <span class="muted">— what a harvestable skill is for THIS store (blank = the engine default)</span>
33
+ <textarea name="harvest_prompt" rows="3" placeholder="what makes a skill harvestable for this store"><%= hv["prompt"] %></textarea>
34
+ </label>
35
+ </fieldset>
@@ -0,0 +1,32 @@
1
+ <fieldset class="subgroup">
2
+ <legend>reliability <span class="muted">— retries, fallback chain, circuit breaker</span></legend>
3
+ <%== inherit_toggle("reliability") %>
4
+ <% rel = @agent.reliability || {}; brk = rel["circuit_breaker"] || {} %>
5
+ <div class="grid-3">
6
+ <label>retries
7
+ <input type="text" name="reliability_retries" value="<%= rel["retries"] %>" inputmode="numeric">
8
+ </label>
9
+ <label>backoff
10
+ <select name="reliability_backoff">
11
+ <option value="">none</option>
12
+ <% %w[constant exponential].each do |m| %>
13
+ <option value="<%= m %>"<%== ' selected' if rel["backoff"] == m %>><%= m %></option>
14
+ <% end %>
15
+ </select>
16
+ </label>
17
+ <label>timeout (s)
18
+ <input type="text" name="reliability_timeout" value="<%= rel["timeout"] %>" placeholder="30" inputmode="numeric">
19
+ </label>
20
+ <label>breaker after <span class="muted">(failures)</span>
21
+ <input type="text" name="reliability_breaker_after" value="<%= brk["after"] %>" inputmode="numeric">
22
+ </label>
23
+ <label>breaker within (s)
24
+ <input type="text" name="reliability_breaker_within" value="<%= brk["within"] %>" inputmode="numeric">
25
+ </label>
26
+ <label>breaker cooldown (s)
27
+ <input type="text" name="reliability_breaker_cooldown" value="<%= brk["cooldown"] %>" inputmode="numeric">
28
+ </label>
29
+ </div>
30
+ <p class="muted">fallback models — tried in this order after retries, before the platform's; blank slots are skipped</p>
31
+ <%== model_slots("reliability_fallback_refs", rel["fallback"]) %>
32
+ </fieldset>
@@ -0,0 +1,66 @@
1
+ <%# Master column of the Chats miller shell — rendered by the index
2
+ (chats.erb) and the detail (session.erb, the read-only viewer). Rows target
3
+ the session-detail turbo-frame with data-turbo-action="advance": the detail
4
+ loads beside the list while the URL moves, so refresh/deep-link render the
5
+ same screen. Local: active_id lights the current row (nil on the index). %>
6
+ <aside class="drill-pane drill-master">
7
+ <div class="drill-pane-head">
8
+ <div class="title"><strong>Conversations <span class="count"><%= @chats_total || @sessions.size %></span></strong></div>
9
+ </div>
10
+ <%# Typing filters the rows shown; Enter searches every conversation by id. %>
11
+ <form class="drill-search" method="get" action="/studio/chats" data-turbo-frame="_top">
12
+ <%# The agent filter: a select, not a column — six rows never earned the width. %>
13
+ <select name="agent" class="filter-input" aria-label="Filter by agent"
14
+ data-controller="auto-submit" data-action="change->auto-submit#submit">
15
+ <option value="">All agents · <%= @chats_all %></option>
16
+ <% @chat_agents.each do |id, count, _last| %>
17
+ <option value="<%= id %>"<%== ' selected' if id == @agent %>><%= id %> · <%= count %></option>
18
+ <% end %>
19
+ </select>
20
+ <% if @status %><input type="hidden" name="status" value="<%= @status %>"><% end %>
21
+ <input type="search" name="q" value="<%= @query %>" class="filter-input" placeholder="Filter… Enter searches all by id" aria-label="Filter conversations"
22
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
23
+ </form>
24
+ <%# Status tabs: all / active now / guardrail flagged — counts follow the agent and search filters. %>
25
+ <nav class="segmented chat-status" aria-label="Status">
26
+ <% [[nil, "All"], ["active", "Active now"], ["flagged", "Guardrail flagged"]].each do |value, label| %>
27
+ <a href="<%= chats_path(status: value) %>" data-turbo-frame="_top"<%== ' aria-current="page"' if @status == value %>>
28
+ <%= label %><% if value %>&nbsp;<span class="count"><%= @status_counts.to_h[value].to_i %></span><% end %>
29
+ </a>
30
+ <% end %>
31
+ </nav>
32
+ <nav class="drill-pane-body drill-list" aria-label="conversations" data-controller="active-link">
33
+ <% active_id = defined?(active_id) ? active_id : nil %>
34
+ <% if @sessions.empty? %>
35
+ <div class="empty"><% if @query || @agent || @status %>No conversations match. <a href="/studio/chats">Show all</a><% else %>No conversations yet. Start one in the <a href="/studio/playground">playground</a>.<% end %></div>
36
+ <% else %>
37
+ <% @sessions.each do |s| %>
38
+ <% customer = presence(session_customer(s)) %>
39
+ <% label = presence(session_customer_label(s)) %>
40
+ <% phone = session_customer_phone(s) %>
41
+ <% agent = presence(session_agent(s)) %>
42
+ <% preview = session_preview(s) %>
43
+ <a class="drill-item<%== ' active' if s.id == active_id %>"
44
+ href="/studio/sessions/<%= Rack::Utils.escape(s.id) %><%= @agent ? "?agent=#{Rack::Utils.escape(@agent)}" : "" %>"
45
+ data-turbo-frame="session-detail" data-turbo-action="advance"
46
+ data-list-filter-target="item" data-filter-text="<%= s.id %> <%= customer %> <%= label %> <%= phone %> <%= agent %> <%= preview %>"
47
+ data-active-link-target="item" data-action="click->active-link#select">
48
+ <div class="drill-item-top">
49
+ <%== render("_identity", locals: { id: customer || s.id, name: label || "anonymous", sub: presence([agent, phone].compact.join(" · ")) }) %>
50
+ <% if session_flagged?(s) %><span class="pill err plain" title="A guardrail flagged a message in this conversation">flagged</span><% end %>
51
+ <span class="presence<%== ' present' if session_fresh?(s) %>" title="<%= session_fresh?(s) ? "Activity in the last 5 minutes" : "No activity in the last 5 minutes" %>"></span>
52
+ </div>
53
+ <div class="drill-item-sub"><%= Array(s.messages).size %> messages · <%= time_ago(s.updated_at) %></div>
54
+ <% if preview %><div class="drill-item-sub"><%== mark_tags(preview, 90) %></div><% end %>
55
+ </a>
56
+ <% end %>
57
+ <div class="empty" data-list-filter-target="empty" hidden>No conversations match your filter.</div>
58
+ <% if @more.to_i.positive? %>
59
+ <a class="btn" href="<%= chats_path(limit: @limit + @more) %>" data-turbo-frame="_top">Load <%= @more %> more</a>
60
+ <% end %>
61
+ <% end %>
62
+ </nav>
63
+ </aside>
64
+ <div class="col-resize" role="separator" aria-orientation="vertical" aria-label="Resize conversation list" tabindex="0"
65
+ data-controller="col-resize" data-col-resize-key-value="chats-list"
66
+ data-action="pointerdown->col-resize#start keydown->col-resize#key dblclick->col-resize#reset"></div>
@@ -0,0 +1,37 @@
1
+ <%# Master column of the Customers miller shell — rendered by the
2
+ index (customers.erb) and the detail (customer.erb). Rows target the
3
+ detail turbo-frame with data-turbo-action="advance": the detail
4
+ loads beside the list while the URL moves, so refresh/deep-link render the
5
+ same screen. Locals: active_scope lights the current row (nil on the index). %>
6
+ <aside class="drill-pane drill-master">
7
+ <div class="drill-pane-head">
8
+ <div class="title"><strong>Customers <span class="count"><%= @rows.size %></span></strong></div>
9
+ </div>
10
+ <div class="drill-search">
11
+ <input type="search" class="filter-input" placeholder="Filter customers by id or tenant…" aria-label="Filter customers"
12
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
13
+ </div>
14
+ <nav class="drill-pane-body drill-list" aria-label="customers" data-controller="active-link">
15
+ <% active_scope = defined?(active_scope) ? active_scope : nil %>
16
+ <% if @rows.empty? %>
17
+ <div class="empty">No customers with memory yet — facts appear here when a turn or the operator writes to a customer cell.</div>
18
+ <% else %>
19
+ <% @rows.each do |row| %>
20
+ <% cell = row[:cell] %>
21
+ <% scope = "#{cell[:tenant]}:#{cell[:customer]}" %>
22
+ <a class="drill-item<%== ' active' if scope == active_scope %>"
23
+ href="<%= customer_path(scope) %><%= @agent ? "?agent=#{Rack::Utils.escape(@agent)}" : "" %>"
24
+ data-turbo-frame="customer-detail" data-turbo-action="advance"
25
+ data-list-filter-target="item" data-filter-text="<%= cell[:customer] %> <%= cell[:tenant] %>"
26
+ data-active-link-target="item" data-action="click->active-link#select">
27
+ <div class="drill-item-name">
28
+ <span class="avatar sm <%= avatar_class(cell[:customer]) %>"><%= cell[:customer][0, 2].upcase %></span>
29
+ <span class="txt" title="<%= cell[:customer] %>"><%= cell[:customer] %></span>
30
+ </div>
31
+ <div class="drill-item-sub"><%= cell[:tenant] %><%== " · #{row[:count]} fact#{row[:count] == 1 ? '' : 's'}" %></div>
32
+ </a>
33
+ <% end %>
34
+ <div class="empty" data-list-filter-target="empty" hidden>No customers match your filter.</div>
35
+ <% end %>
36
+ </nav>
37
+ </aside>
@@ -0,0 +1,15 @@
1
+ <%# Empty state: why this list is empty and, where there is one, the single
2
+ thing to do about it. Locals:
3
+ icon: — a key from Studio::NavIcons::NAV_ICONS
4
+ text: — the message
5
+ action_href: — optional link for the one action
6
+ action_label: — its label; both must be present for the action to render. %>
7
+ <% action_href = defined?(action_href) ? action_href : nil %>
8
+ <% action_label = defined?(action_label) ? action_label : nil %>
9
+ <div class="empty">
10
+ <span class="ic" aria-hidden="true"><%== nav_icon(icon) %></span>
11
+ <p class="msg"><%= text %></p>
12
+ <% if action_href && action_label %>
13
+ <p class="act"><a class="btn" href="<%= action_href %>"><%= action_label %></a></p>
14
+ <% end %>
15
+ </div>
@@ -0,0 +1,42 @@
1
+ <%# Master column of the Facts miller shell — rendered by the index
2
+ (facts.erb) and the detail (fact_detail.erb). ONE queue, pending first
3
+ (oldest first — evidence ages), then stale (the CAS-lost re-presents),
4
+ then recent (the audit trail) — never per-status pages, just labelled
5
+ groups in the same scrollable list. Rows target the fact-detail
6
+ turbo-frame with data-turbo-action="advance": the detail loads beside the
7
+ list while the URL moves, so refresh/deep-link render the same screen.
8
+ Local: active_id lights the current row. %>
9
+ <aside class="drill-pane drill-master">
10
+ <div class="drill-pane-head">
11
+ <div class="title"><strong>Review queue <span class="count"><%= @pending.size + @stale.size + @recent.size %></span></strong><span class="count">Oldest pending first</span></div>
12
+ </div>
13
+ <div class="drill-search">
14
+ <input type="search" class="filter-input" placeholder="Search facts…" aria-label="Filter facts by key, value or scope"
15
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
16
+ </div>
17
+ <nav class="drill-pane-body drill-list" aria-label="facts" data-controller="active-link">
18
+ <% if @pending.empty? && @stale.empty? && @recent.empty? %>
19
+ <div class="empty">Nothing to show yet.</div>
20
+ <% else %>
21
+ <% [["Pending", @pending], ["Stale", @stale], ["Recent", @recent]].each do |label, rows| %>
22
+ <% next if rows.empty? %>
23
+ <div class="drill-group-label"><%= label %><span><%= rows.size %></span></div>
24
+ <% rows.each do |p| %>
25
+ <a class="drill-item<%== ' active' if p.id == active_id %>"
26
+ href="/studio/facts/<%= Rack::Utils.escape(p.id) %><%= "?agent=#{Rack::Utils.escape(@filter)}" if @filter %>"
27
+ data-turbo-frame="fact-detail" data-turbo-action="advance"
28
+ data-list-filter-target="item" data-filter-text="<%= p.key %> <%= p.key.tr('_', ' ') %> <%= p.value %> <%= p.scope %>"
29
+ data-active-link-target="item" data-action="click->active-link#select">
30
+ <span class="fact-item-key"><%= p.key.tr("_", " ").sub(/\A./, &:upcase) %></span>
31
+ <strong class="fact-item-value"><%= p.value %></strong>
32
+ <div class="drill-item-sub fact-item-meta">
33
+ <span class="status <%= status_class(p.status) %>"><%= p.status %></span>
34
+ <span class="fact-item-age"><%= time_ago(%w[pending stale].include?(p.status) ? p.created_at : p.updated_at) %></span>
35
+ </div>
36
+ </a>
37
+ <% end %>
38
+ <% end %>
39
+ <div class="empty" data-list-filter-target="empty" hidden>No facts match your filter.</div>
40
+ <% end %>
41
+ </nav>
42
+ </aside>
@@ -0,0 +1,14 @@
1
+ <%# What a person needs to judge a candidate before gating it: why it was
2
+ proposed, where it came from, and the skill text itself. %>
3
+ <details class="review-details">
4
+ <summary>View skill</summary>
5
+ <p class="muted"><%= cand.rationale %></p>
6
+ <% if Array(cand.origin).any? %>
7
+ <p class="muted">From:
8
+ <% Array(cand.origin).each do |sid| %>
9
+ <a href="/studio/sessions/<%= Rack::Utils.escape(sid) %>?agent=<%= Rack::Utils.escape(cand.agent) %>" class="mono" data-turbo-frame="_top"><%= sid %></a>
10
+ <% end %>
11
+ </p>
12
+ <% end %>
13
+ <div class="markdown-preview" data-controller="markdown"><%= cand.body %></div>
14
+ </details>