insika 0.3.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -1,828 +1,69 @@
1
- <header class="page-head">
2
- <div>
3
- <nav class="crumbs"><a href="/studio/agents">agents</a> / <span><%= @agent.id %></span></nav>
4
- <h1><span class="avatar sm <%= avatar_class(@agent.id) %>"><%= @agent.id[0, 2].upcase %></span><%= @agent.id %>
5
- <span class="sub"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
6
- </h1>
7
- </div>
8
- <a class="btn primary" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">Open playground →</a>
9
- </header>
10
-
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>
21
- </nav>
22
-
23
- <!-- ── Config / model ────────────────────────────────────────────────────── -->
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" %>>
64
- <div class="grid-2">
65
- <label>model <span class="muted">(blank = platform <code>default_model</code>)</span>
66
- <input type="text" name="model" value="<%= @agent.model %>" placeholder="inherit platform default">
67
- </label>
68
- <label>provider
69
- <input type="text" name="provider" value="<%= @agent.provider %>" placeholder="deepseek">
70
- </label>
71
- <label>turn_timeout (s)
72
- <input type="text" name="turn_timeout" value="<%= @agent.limits[:turn_timeout] %>" inputmode="numeric">
73
- </label>
74
- <label>tool_timeout (s)
75
- <input type="text" name="tool_timeout" value="<%= @agent.limits[:tool_timeout] %>" inputmode="numeric">
76
- </label>
77
- <label>tool_concurrency <span class="muted">(parallel tool calls; 1 = serial, off for turns with approvals)</span>
78
- <input type="text" name="tool_concurrency" value="<%= @agent.limits[:tool_concurrency] %>" inputmode="numeric">
79
- </label>
80
- <label>chat_rate_limit <span class="muted">(turns/chat/window; blank = platform, 0 = off)</span>
81
- <input type="text" name="chat_rate_limit" value="<%= @agent.limits[:chat_rate_limit] %>" inputmode="numeric" placeholder="inherit platform edge">
82
- </label>
83
- <label>agent_token_ceiling <span class="muted">(tokens/window; blank = platform, 0 = off)</span>
84
- <input type="text" name="agent_token_ceiling" value="<%= @agent.limits[:agent_token_ceiling] %>" inputmode="numeric" placeholder="inherit platform edge">
85
- </label>
1
+ <%# Agent detail — the DETAIL pane of the Agents miller shell.
2
+ Full page (direct visit/refresh): the two-column shell — master list plus
3
+ this pane. Turbo-Frame request (a row click, a config save, a prompt-file
4
+ switch): the pane alone, so the master's scroll state and selection
5
+ survive every interaction. The tab sections live in _agent_tab_* partials
6
+ (one per section, named after it — no abstraction, just containment). %>
7
+ <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
8
+ <% frame_only = defined?(frame_only) && frame_only %>
9
+ <% unless frame_only %>
10
+ <header class="page-head">
11
+ <div>
12
+ <nav class="crumbs"><a href="/studio/agents">agents</a> / <span><%= @agent.id %></span></nav>
13
+ <h1><span class="avatar sm <%= avatar_class(@agent.id) %>"><%= @agent.id[0, 2].upcase %></span><%= @agent.id %>
14
+ <span class="sub"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
15
+ </h1>
86
16
  </div>
87
- <label class="check">
88
- <input type="checkbox" name="memory" value="1"<%== " checked" if @agent.memory %>>
89
- cross-session memory (the agent remembers across conversations)
90
- </label>
91
-
92
- <fieldset class="subgroup">
93
- <legend>generation params <span class="muted">— blank = provider default</span></legend>
94
- <div class="grid-3">
95
- <label>temperature
96
- <input type="text" name="temperature" value="<%= agent_param(@params, :temperature) %>" inputmode="decimal" placeholder="0.7">
97
- </label>
98
- <label>max_tokens
99
- <input type="text" name="max_tokens" value="<%= agent_param(@params, :max_tokens) %>" inputmode="numeric" placeholder="4096">
100
- </label>
101
- <label>reasoning <span class="muted">(off/on/effort — blank = inherit model/platform)</span>
102
- <%== thinking_select("thinking", agent_param(@params, :thinking), "inherit") %>
103
- </label>
104
- </div>
105
- </fieldset>
106
-
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>
108
- <textarea name="model_policy_allow" rows="3" placeholder="deepseek/*&#10;openai/gpt-4o-mini"><%= @model_policy_allow %></textarea>
109
- </label>
110
- </div>
111
-
112
- <div class="config-group" data-config-group="guardrails"<%== " hidden" unless @config_group == "guardrails" %>>
113
- <fieldset class="subgroup">
114
- <legend>guardrails <span class="muted">— content safety</span></legend>
115
- <label class="check">
116
- <input type="checkbox" name="guardrail_input" value="1"<%== " checked" if guardrail_field(@guardrails, :input, true) %>>
117
- input guardrail <span class="muted">— block injection / abuse / inappropriate before the LLM</span>
118
- </label>
119
- <label class="check">
120
- <input type="checkbox" name="guardrail_output" value="1"<%== " checked" if guardrail_field(@guardrails, :output, true) %>>
121
- output guardrail <span class="muted">— redact PII/secret in the stream + flag policy slips</span>
122
- </label>
123
- <div class="grid-2">
124
- <label>strictness
125
- <% strictness = guardrail_field(@guardrails, :strictness, "medium").to_s %>
126
- <select name="guardrail_strictness">
127
- <% %w[low medium high].each do |lvl| %>
128
- <option value="<%= lvl %>"<%== " selected" if lvl == strictness %>><%= lvl %></option>
129
- <% end %>
130
- </select>
131
- </label>
132
- <label>LLM moderator <span class="muted">(model ref, or <code>on</code> for the platform utility_model; blank = deterministic only)</span>
133
- <input type="text" name="guardrail_moderator" value="<%= guardrail_field(@guardrails, :moderator, "") %>" placeholder="deepseek/deepseek-v4-flash">
134
- </label>
135
- </div>
136
- <details class="newfile">
137
- <summary>safe replies — override per category <span class="muted">(blank = engine default; this agent's voice/language)</span></summary>
138
- <% gr_resp = (@guardrails["responses"] || @guardrails[:responses] || {}) %>
139
- <label>default <span class="muted">(catch-all for any blocked category)</span>
140
- <textarea name="guardrail_response_default" rows="2" placeholder="I can't help with that particular request…"><%= guardrail_field(gr_resp, :default, "") %></textarea>
141
- </label>
142
- <div class="grid-2">
143
- <% %w[injection sexual abuse escalate].each do |cat| %>
144
- <label><%= cat %>
145
- <textarea name="guardrail_response_<%= cat %>" rows="2"><%= guardrail_field(gr_resp, cat, "") %></textarea>
146
- </label>
147
- <% end %>
148
- </div>
149
- </details>
150
- </fieldset>
151
- </div>
152
-
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>
490
- </section>
491
-
492
- <!-- ── Prompts / identity (drill: file list | editor, like skills) ────────── -->
493
- <section class="section" id="prompts" data-tabs-target="panel">
494
- <div class="section-head">
495
- <h2>Prompts <span class="pill info plain"><%= @prompt_files.size %></span></h2>
496
- <p class="muted">the agent's own identity (store-backed, versioned). Empty = uses the deployment default.</p>
497
- </div>
17
+ <a class="btn primary" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">Open playground →</a>
18
+ </header>
498
19
 
499
20
  <div class="drill" data-controller="list-filter">
500
- <aside class="drill-pane drill-master">
21
+ <%== render("_agents_master", locals: { active_id: @agent.id }) %>
22
+ <% end %>
23
+ <turbo-frame id="agent-detail" class="drill-pane drill-detail">
24
+ <%# A frame response never sees the layout, so the flash strip lives here
25
+ too — without it, save confirmations would vanish on frame submits
26
+ (the layout's copy only renders on full pages). %>
27
+ <% if frame_only %>
28
+ <% if flash["error"] %><div class="flash err" role="alert"><%= flash["error"] %></div><% end %>
29
+ <% if flash["notice"] %><div class="flash ok" role="status"><%= flash["notice"] %></div><% end %>
30
+ <% end %>
501
31
  <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>
504
- </div>
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>
533
- </div>
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>
32
+ <div class="title">
33
+ <strong class="mono"><%= @agent.id %></strong>
34
+ <span class="count"><%= @agent.model %><%== " · #{h(@agent.provider)}" if @agent.provider %></span>
542
35
  </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 %>">
552
- <% 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>
558
-
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 %>
36
+ <a class="btn btn-sm" href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>" data-turbo-frame="_top">Test in playground</a>
37
+ </div>
38
+ <div class="drill-pane-body">
39
+ <div class="tabs-page" data-controller="tabs" data-tabs-default-value="<%= @active_tab %>">
40
+ <nav class="subnav" aria-label="agent sections">
41
+ <%# data-turbo="false": these are plain in-page hash links. Without
42
+ the opt-out, Turbo intercepts them as frame navigations (they sit
43
+ inside turbo-frame#agent-detail), dropping the #fragment and
44
+ re-fetching the frame instead of letting the browser set
45
+ location.hash — which is the only thing tabs_controller reads. %>
46
+ <a href="#config" data-tabs-target="tab" data-turbo="false">config</a>
47
+ <a href="#prompts" data-tabs-target="tab" data-turbo="false">prompts</a>
48
+ <a href="#skills" data-tabs-target="tab" data-turbo="false">skills</a>
49
+ <a href="#memory" data-tabs-target="tab" data-turbo="false">memory</a>
50
+ <a href="#outcomes" data-tabs-target="tab" data-turbo="false">outcomes</a>
51
+ <a href="#loops" data-tabs-target="tab" data-turbo="false">loops</a>
52
+ <a href="#cache" data-tabs-target="tab" data-turbo="false">cache</a>
53
+ <a href="#history" data-tabs-target="tab" data-turbo="false">history</a>
54
+ </nav>
55
+
56
+ <%== render("_agent_tab_config") %>
57
+ <%== render("_agent_tab_prompts") %>
58
+ <%== render("_agent_tab_skills") %>
59
+ <%== render("_agent_tab_memory") %>
60
+ <%== render("_agent_tab_loops") %>
61
+ <%== render("_agent_tab_outcomes") %>
62
+ <%== render("_agent_tab_cache") %>
63
+ <%== render("_agent_tab_history") %>
594
64
  </div>
595
- <% end %>
596
- </section>
597
- </div>
598
- </section>
599
-
600
- <!-- ── Agent skills ──────────────────────────────────────────────────────── -->
601
- <section class="section" id="skills" data-tabs-target="panel">
602
- <div class="section-head">
603
- <h2>Skills</h2>
604
- <p class="muted">which catalog skills this agent may load (<code>load_skill</code>).</p>
605
- </div>
606
- <form class="card" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/skills" data-controller="toggle-counter">
607
- <%== csrf_tag %>
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">
623
- <% @all_skills.sort_by(&:name).each do |s| %>
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">
627
- <span class="mono"><%= s.name %></span>
628
- </label>
629
- <% end %>
630
65
  </div>
631
- <% end %>
632
- <div class="actions">
633
- <button type="submit" class="primary" data-turbo-submits-with="Saving…">Save skills</button>
634
- <a class="btn ghost" href="/studio/tools">Edit tools →</a>
635
- </div>
636
- </form>
637
- </section>
638
-
639
- <!-- ── Agent memory (tenant = id) ────────────────────────────────────────── -->
640
- <section class="section" id="memory" data-tabs-target="panel">
641
- <div class="section-head">
642
- <h2>Memory</h2>
643
- <p class="muted">facts and notes the agent reads each turn (tenant <code><%= @agent.id %></code>).</p>
644
- </div>
645
- <div class="card">
646
- <h3>Facts</h3>
647
- <% if @facts.empty? %>
648
- <p class="muted">No facts.</p>
649
- <% else %>
650
- <ul class="kv">
651
- <% @facts.each do |f| %>
652
- <li>
653
- <span class="k mono"><%= f.key %></span>
654
- <span class="v"><%= f.value %></span>
655
- <form method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/memory/forget" data-turbo-confirm="Forget &quot;<%= f.key %>&quot;? The agent loses this fact from the next turn on.">
656
- <%== csrf_tag %>
657
- <input type="hidden" name="key" value="<%= f.key %>">
658
- <button type="submit" class="ghost danger" data-turbo-submits-with="Forgetting…">Forget</button>
659
- </form>
660
- </li>
661
- <% end %>
662
- </ul>
663
- <% end %>
664
- <form class="inline-form" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/memory/fact">
665
- <%== csrf_tag %>
666
- <input type="text" name="key" placeholder="key (e.g. customer_name)" required>
667
- <input type="text" name="value" placeholder="value" required>
668
- <button type="submit" class="primary" data-turbo-submits-with="Saving…">Save fact</button>
669
- </form>
670
- </div>
671
-
672
- <div class="card">
673
- <h3>Notes <span class="muted">(recent)</span></h3>
674
- <% if @notes.empty? %>
675
- <p class="muted">No notes.</p>
676
- <% else %>
677
- <ul class="notes">
678
- <% @notes.each do |n| %>
679
- <li><span class="muted mono"><%= n.created_at %></span> <%= n.text %></li>
680
- <% end %>
681
- </ul>
682
- <% end %>
683
- <form class="inline-form" method="post" action="/studio/agents/<%= Rack::Utils.escape(@agent.id) %>/memory/note">
684
- <%== csrf_tag %>
685
- <input type="text" name="text" placeholder="free-form note…" required>
686
- <button type="submit" class="primary" data-turbo-submits-with="Adding…">Add note</button>
687
- </form>
66
+ </turbo-frame>
67
+ <% unless frame_only %>
688
68
  </div>
689
- </section>
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
-
804
- <!-- ── History ───────────────────────────────────────────────────────────── -->
805
- <section class="section" id="history" data-tabs-target="panel">
806
- <div class="section-head">
807
- <h2>History</h2>
808
- <p class="muted">recent conversations (all agents — a session doesn't stamp its author).</p>
809
- </div>
810
- <% if @recent_sessions.empty? %>
811
- <div class="empty">No conversations yet. Start one in the <a href="/studio/playground?agent=<%= Rack::Utils.escape(@agent.id) %>">playground</a>.</div>
812
- <% else %>
813
- <div class="card">
814
- <ul class="history">
815
- <% @recent_sessions.each do |s| %>
816
- <li>
817
- <a href="/studio/sessions/<%= Rack::Utils.escape(s.id) %>">
818
- <span class="mono"><%= s.id[0, 12] %></span>
819
- </a>
820
- <span class="muted"><%= Array(s.messages).size %> msg · <%= s.updated_at %></span>
821
- <% if (p = session_preview(s)) %><span class="preview muted"><%= p[0, 80] %></span><% end %>
822
- </li>
823
- <% end %>
824
- </ul>
825
- </div>
826
- <% end %>
827
- </section>
828
- </div>
69
+ <% end %>