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
@@ -4,10 +4,15 @@
4
4
  ["general", "General", "streaming, timeouts"],
5
5
  ["models", "Model defaults", "platform model layer (Chat › Agent › platform)"],
6
6
  ["edge", "Edge limits", "rate-limit per chat, token ceiling per agent"],
7
+ ["burst", "Burst policy", "what happens to a message that lands on a busy session"],
8
+ ["agents", "Agent defaults", "what an agent with no value of its own runs with"],
7
9
  ["evals", "Evals", "who grades a rubric, and how a panel agrees"],
8
10
  ["llm", "LLM providers", "keys, base URL, models per provider"],
9
11
  ["demo", "Demo data", "see every loop working at once"]
10
12
  ] %>
13
+ <header class="page-head">
14
+ <h1>Settings<span class="sub">Platform defaults · agents can override supported settings</span></h1>
15
+ </header>
11
16
  <div class="drill">
12
17
  <aside class="drill-pane drill-master">
13
18
  <div class="drill-pane-head"><div class="title"><strong>Settings</strong></div></div>
@@ -25,11 +30,11 @@
25
30
  <% if @section == "general" %>
26
31
  <div class="drill-pane-head">
27
32
  <div class="title"><strong>General</strong></div>
28
- <button type="submit" form="set-general" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
33
+ <button type="submit" form="set-general" class="btn primary" data-turbo-submits-with="Saving…">Save general settings</button>
29
34
  </div>
30
35
  <div class="drill-pane-body">
31
- <p class="muted">Streaming and timeouts — saved via <code>update_settings</code> (durable, applies next turn).</p>
32
- <form id="set-general" method="post" action="/studio/settings">
36
+ <p class="muted">Save these platform defaults to apply them on future turns.</p>
37
+ <form id="set-general" method="post" action="/studio/settings" data-controller="dirty-guard">
33
38
  <%== csrf_tag %>
34
39
  <label class="check">
35
40
  <input type="checkbox" name="streaming" value="1"<%== " checked" if @settings["streaming"] %>>
@@ -43,7 +48,8 @@
43
48
  <label>Memory TTL (days)<input type="text" name="memory_ttl_days" value="<%= @settings["memory_ttl_days"] %>" inputmode="numeric" placeholder="blank = off"></label>
44
49
  </div>
45
50
  <p class="muted">Memory TTL: how many days a customer memory cell lives before the daily sweep prunes it (per-fact <code>expires_at</code> overrides always win). Editing here sets the platform default for <strong>every</strong> cell; a per-tenant map authored in the settings record is replaced by this save.</p>
46
- <hr>
51
+ <details class="newfile">
52
+ <summary>Conversation compaction</summary>
47
53
  <label class="check">
48
54
  <input type="checkbox" name="compaction_enabled" value="1"<%== " checked" if @settings.dig("compaction", "enabled") %>>
49
55
  in-session compaction — summarize old turns past the threshold
@@ -52,50 +58,58 @@
52
58
  <label>keep_last <span class="muted">(messages kept verbatim)</span><input type="text" name="compaction_keep_last" value="<%= @settings.dig("compaction", "keep_last") %>" inputmode="numeric"></label>
53
59
  <label>compact_after <span class="muted">(uncompacted messages that trigger it)</span><input type="text" name="compaction_compact_after" value="<%= @settings.dig("compaction", "compact_after") %>" inputmode="numeric"></label>
54
60
  </div>
55
- <label>compaction model <span class="muted">(blank = the platform utility_model)</span><input type="text" name="compaction_model" value="<%= @settings.dig("compaction", "model") %>" placeholder="deepseek-v4-flash"></label>
61
+ <label>compaction model <span class="muted">(blank = the platform utility_model)</span><%== model_select("compaction_model_ref", @settings.dig("compaction", "model"), "platform utility_model") %></label>
56
62
  <p class="muted">When a session's uncompacted transcript grows past <code>compact_after</code> messages, everything but the last <code>keep_last</code> is summarized by the cheap model into one <code>&lt;conversation_summary&gt;</code> fragment; the tail stays verbatim and the boundary is stable (the prompt cache holds after it). Runs post-turn, off the critical path.</p>
63
+ </details>
57
64
  </form>
58
65
  </div>
59
66
 
60
67
  <% elsif @section == "models" %>
61
68
  <div class="drill-pane-head">
62
69
  <div class="title"><strong>Model defaults</strong></div>
63
- <button type="submit" form="set-models" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
70
+ <button type="submit" form="set-models" class="btn primary" data-turbo-submits-with="Saving…">Save model defaults</button>
64
71
  </div>
65
72
  <div class="drill-pane-body">
66
73
  <p class="muted">The platform model layer, resolved at turn start as <strong>Chat pin › Agent model › platform default</strong>. An agent with no model of its own inherits <code>default_model</code>.</p>
67
- <form id="set-models" method="post" action="/studio/settings/models">
74
+ <form id="set-models" method="post" action="/studio/settings/models" data-controller="dirty-guard">
68
75
  <%== csrf_tag %>
69
- <div class="grid-2">
70
- <label>default_model <span class="muted">(agents without their own model)</span><input type="text" name="default_model" value="<%= @settings["default_model"] %>" placeholder="deepseek-v4-flash"></label>
71
- <label>default_provider <span class="muted">(optional)</span><input type="text" name="default_provider" value="<%= @settings["default_provider"] %>" placeholder="deepseek"></label>
72
- </div>
73
- <label>fallback_models <span class="muted">(ordered chain; <code>provider/model</code> or <code>model</code>, comma or newline)</span><input type="text" name="fallback_models" value="<%= Array(@settings["fallback_models"]).join(", ") %>" placeholder="deepseek/deepseek-v4-flash, openai/gpt-4o-mini"></label>
74
- <label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span><input type="text" name="utility_model" value="<%= @settings["utility_model"] %>" placeholder="deepseek-v4-flash"></label>
76
+ <p class="muted">The lists below are the models of the configured LLM providers — add a provider or a model there to see it here.</p>
77
+ <label>default_model <span class="muted">(agents without their own model)</span>
78
+ <%== model_select("default_model_ref", model_ref_of(@settings["default_model"], @settings["default_provider"]), "none") %>
79
+ </label>
80
+ <fieldset class="subgroup">
81
+ <legend>fallback_models <span class="muted">— tried in this order; blank slots are skipped</span></legend>
82
+ <%== model_slots("fallback_refs", @settings["fallback_models"], min: 5) %>
83
+ </fieldset>
84
+ <label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span>
85
+ <%== model_select("utility_model_ref", @settings["utility_model"], "none") %>
86
+ </label>
75
87
  <label>reasoning <span class="muted">(global default — off/on/effort; blank = provider default)</span>
76
88
  <%== thinking_select("thinking", @settings["thinking"], "provider default") %>
77
89
  </label>
78
90
  </form>
79
91
 
80
- <hr>
81
- <p class="muted">Per-model reasoning defaults — the layer between global and agent (resolved <strong>Chat › Agent › Model › Global</strong>). One per line.</p>
82
- <form id="set-model-params" method="post" action="/studio/settings/model-params">
92
+ <details class="newfile">
93
+ <summary>Reasoning overrides by model</summary>
94
+ <p class="muted">Saved separately from platform model defaults. Per-model reasoning defaults — the layer between global and agent (resolved <strong>Chat › Agent › Model › Global</strong>). One per line.</p>
95
+ <form id="set-model-params" method="post" action="/studio/settings/model-params" data-controller="dirty-guard">
83
96
  <%== csrf_tag %>
84
97
  <label>model_params <span class="muted">(one per line: <code>provider/model | off|on|low|medium|high</code>)</span>
85
98
  <textarea name="model_params" rows="4" placeholder="deepseek/deepseek-v4-flash | off"><%= model_params_text(@settings["model_params"]) %></textarea>
86
99
  </label>
87
100
  <div class="actions"><button type="submit" class="btn primary" data-turbo-submits-with="Saving…">Save per-model</button></div>
88
101
  </form>
102
+ </details>
89
103
  </div>
90
104
 
91
105
  <% elsif @section == "edge" %>
92
106
  <div class="drill-pane-head">
93
107
  <div class="title"><strong>Edge limits</strong></div>
94
- <button type="submit" form="set-edge" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
108
+ <button type="submit" form="set-edge" class="btn primary" data-turbo-submits-with="Saving…">Save edge limits</button>
95
109
  </div>
96
110
  <div class="drill-pane-body">
97
111
  <p class="muted">The named place where volume/cost abuse is cut, <strong>before any LLM spend</strong>. Blank/0 = off (opt-in). A breached turn completes gracefully with the safe reply below. Per-agent overrides live in each agent's config.</p>
98
- <form id="set-edge" method="post" action="/studio/settings/edge">
112
+ <form id="set-edge" method="post" action="/studio/settings/edge" data-controller="dirty-guard">
99
113
  <%== csrf_tag %>
100
114
  <div class="grid-2">
101
115
  <label>chat_rate_limit <span class="muted">(turn attempts per chat per window)</span><input type="text" name="chat_rate_limit" value="<%= @settings.dig("edge", "chat_rate_limit") %>" inputmode="numeric" placeholder="off"></label>
@@ -107,14 +121,64 @@
107
121
  </form>
108
122
  </div>
109
123
 
124
+ <% elsif @section == "burst" %>
125
+ <div class="drill-pane-head">
126
+ <div class="title"><strong>Burst policy</strong></div>
127
+ <button type="submit" form="set-queue" class="btn primary" data-turbo-submits-with="Saving…">Save burst policy</button>
128
+ </div>
129
+ <div class="drill-pane-body">
130
+ <p class="muted">What happens to a message that lands on a session that is <strong>already busy</strong>: <code>followup</code> waits in line (today's behavior), <code>collect</code> merges fragments that arrived before the turn started, <code>steer</code> also appends into the run in flight at a tool boundary, <code>interrupt</code> abandons it. This is the <strong>platform</strong> answer — every agent still overrides it in its own config, and a session var pins one conversation. Blank = no platform default (falls through to followup / no window / steer_max 5).</p>
131
+ <form id="set-queue" method="post" action="/studio/settings/queue" data-controller="dirty-guard">
132
+ <%== csrf_tag %>
133
+ <label>queue_mode
134
+ <select name="queue_mode">
135
+ <option value=""<%== " selected" if @settings.dig("queue", "queue_mode").nil? %>>— none (followup)</option>
136
+ <% Insika::QueuePolicy::MODES.each do |mode| %>
137
+ <option value="<%= mode %>"<%== " selected" if @settings.dig("queue", "queue_mode").to_s == mode.to_s %>><%= mode %></option>
138
+ <% end %>
139
+ </select>
140
+ </label>
141
+ <div class="grid-2">
142
+ <label>debounce_ms <span class="muted">(quiet window at the door before the turn starts; blank/0 = none)</span><input type="text" name="debounce_ms" value="<%= @settings.dig("queue", "debounce_ms") %>" inputmode="numeric" placeholder="0"></label>
143
+ <label>debounce_max_ms <span class="muted">(ceiling on the TOTAL wait; blank = 10000)</span><input type="text" name="debounce_max_ms" value="<%= @settings.dig("queue", "debounce_max_ms") %>" inputmode="numeric" placeholder="10000"></label>
144
+ <label>steer_max_messages <span class="muted">(messages ONE running turn may absorb; blank = 5, 0 = never steer)</span><input type="text" name="steer_max_messages" value="<%= @settings.dig("queue", "steer_max_messages") %>" inputmode="numeric" placeholder="5"></label>
145
+ </div>
146
+ <label>steer_join <span class="muted">(template framing an absorbed message — at the door and mid-run; must carry <%= Insika::QueuePolicy::JOIN_PLACEHOLDER %>; blank = the raw text)</span><input type="text" name="steer_join" value="<%= @settings.dig("queue", "steer_join") %>" placeholder="the customer just added: <%= Insika::QueuePolicy::JOIN_PLACEHOLDER %>"></label>
147
+ </form>
148
+ </div>
149
+
150
+ <% elsif @section == "agents" %>
151
+ <div class="drill-pane-head">
152
+ <div class="title"><strong>Agent defaults</strong></div>
153
+ <button type="submit" form="set-agent-defaults" class="btn primary" data-turbo-submits-with="Saving…">Save agent defaults</button>
154
+ </div>
155
+ <div class="drill-pane-body">
156
+ <p class="muted">An agent with no value of its own for one of these sections runs with this one. An agent that sets the section keeps its own, whole. In an agent's config, the <em>use the platform default</em> box says which one applies.</p>
157
+ <form id="set-agent-defaults" method="post" action="/studio/settings/agent-defaults" data-controller="dirty-guard">
158
+ <%== csrf_tag %>
159
+ <%== render("_cfg_reliability") %>
160
+ <%== render("_cfg_budget") %>
161
+ <%== render("_agent_config_retrieval") %>
162
+ <%== render("_cfg_guardrails") %>
163
+ <%== render("_cfg_grounding") %>
164
+ <%== render("_cfg_followup") %>
165
+ <%== render("_cfg_distill") %>
166
+ <%== render("_cfg_harvest") %>
167
+ <fieldset class="subgroup">
168
+ <legend>alerts</legend>
169
+ <%== render("_cfg_alerts") %>
170
+ </fieldset>
171
+ </form>
172
+ </div>
173
+
110
174
  <% elsif @section == "evals" %>
111
175
  <div class="drill-pane-head">
112
176
  <div class="title"><strong>Evals</strong></div>
113
- <button type="submit" form="set-evals" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
177
+ <button type="submit" form="set-evals" class="btn primary" data-turbo-submits-with="Saving…">Save eval settings</button>
114
178
  </div>
115
179
  <div class="drill-pane-body">
116
180
  <p class="muted">Who grades a <a href="/studio/evals">case</a>'s rubric. <strong>One model per line</strong> as <code>provider/model</code> — several lines make a PANEL, and two different models disagreeing is the signal worth having (sampling one model N times mostly measures its own variance). Empty = deterministic assertions only, and rubric'd cases read as <code>judge_pending</code>.</p>
117
- <form id="set-evals" method="post" action="/studio/settings/evals">
181
+ <form id="set-evals" method="post" action="/studio/settings/evals" data-controller="dirty-guard">
118
182
  <%== csrf_tag %>
119
183
  <label>judges<textarea name="judges" rows="4" spellcheck="false" placeholder="deepseek/deepseek-v4-flash&#10;openai/gpt-5-mini"><%= Array(@settings.dig("evals", "judges")).map { |j| [j["provider"], j["model"]].compact.reject(&:empty?).join("/") }.join("\n") %></textarea></label>
120
184
  <div class="grid-2">
@@ -185,7 +249,7 @@
185
249
  <button type="submit" class="ghost danger" data-turbo-submits-with="Removing…">Remove</button>
186
250
  </form>
187
251
  </div>
188
- <form method="post" action="/studio/settings/providers">
252
+ <form method="post" action="/studio/settings/providers" data-controller="dirty-guard">
189
253
  <%== csrf_tag %>
190
254
  <input type="hidden" name="api" value="<%= p["api"] %>">
191
255
  <div class="grid-2">
@@ -201,7 +265,7 @@
201
265
 
202
266
  <details class="card newfile"<%== " open" if @providers.empty? %>>
203
267
  <summary>+ new provider</summary>
204
- <form method="post" action="/studio/settings/providers">
268
+ <form method="post" action="/studio/settings/providers" data-controller="dirty-guard">
205
269
  <%== csrf_tag %>
206
270
  <div class="grid-2">
207
271
  <label>api <span class="muted">(slug, e.g. deepseek)</span><input type="text" name="api" placeholder="openai" required></label>
@@ -4,6 +4,9 @@
4
4
  agent's own version of the skill, not the shared one. %>
5
5
  <% scope = @scope_agent %>
6
6
  <% form_action = scope ? "/studio/agents/#{Rack::Utils.escape(scope)}/skills/#{Rack::Utils.escape(@selected.to_s)}" : "/studio/skills" %>
7
+ <header class="page-head">
8
+ <h1>Skills<span class="sub">Reusable instructions and agent availability</span></h1>
9
+ </header>
7
10
  <div class="drill" data-controller="list-filter">
8
11
  <aside class="drill-pane drill-master">
9
12
  <div class="drill-pane-head">
@@ -13,15 +16,26 @@
13
16
  <div class="drill-search">
14
17
  <input type="search" class="filter-input" placeholder="Search skills…" aria-label="Filter skills"
15
18
  data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
19
+ <label class="switch" title="show only skills no agent can load">
20
+ <input type="checkbox" id="only-unused">
21
+ <span class="switch-track" aria-hidden="true"></span>
22
+ <span class="switch-label">only unused</span>
23
+ </label>
16
24
  </div>
17
25
  <nav class="drill-pane-body drill-list" aria-label="skills">
18
- <% @skills.each do |s| %>
19
- <a class="drill-item<%== ' active' if @selected == s.name %>"
20
- href="/studio/skills/<%= Rack::Utils.escape(s.name) %>"
21
- data-list-filter-target="item" data-filter-text="<%= s.name %> <%= s.description %>">
22
- <div class="drill-item-name"><span class="txt" title="<%= s.name %>"><%= s.name %></span><% if @stored.include?(s.name) %><span class="pill info">authored</span><% end %></div>
23
- <div class="drill-item-sub"><%= s.description %></div>
24
- </a>
26
+ <% @skill_groups.each do |label, skills| %>
27
+ <details class="drill-group" data-list-filter-target="group" open>
28
+ <summary class="drill-group-label"><%= label %> <span class="count"><%= skills.size %></span></summary>
29
+ <% skills.each do |s| %>
30
+ <% users = @skill_reach[s.name].size %>
31
+ <a class="drill-item<%== ' active' if @selected == s.name %>"
32
+ href="/studio/skills/<%= Rack::Utils.escape(s.name) %>"<%== " data-unused" if users.zero? %>
33
+ data-list-filter-target="item" data-filter-text="<%= s.name %> <%= s.description %>">
34
+ <div class="drill-item-name"><span class="txt" title="<%= s.name %>"><%= s.name %></span><% if @stored.include?(s.name) %><span class="pill info">authored</span><% end %></div>
35
+ <div class="drill-item-sub"><span<%== ' class="warn-text"' if users.zero? %>><%= users %> <%= users == 1 ? "agent" : "agents" %></span> · <%= s.description %></div>
36
+ </a>
37
+ <% end %>
38
+ </details>
25
39
  <% end %>
26
40
  <div class="empty" data-list-filter-target="empty" hidden>No skills match your filter.</div>
27
41
  </nav>
@@ -67,12 +81,36 @@
67
81
  <input type="text" name="name" placeholder="my-skill" required>
68
82
  </label>
69
83
  <% end %>
70
- <label>SKILL.md <span class="muted">YAML frontmatter (<code>name</code>, <code>description</code>) + body</span></label>
84
+ <p class="muted"><%= scope ? "Saving updates this agent’s version only." : "Saving updates the shared skill. Agent availability is saved separately below." %></p>
85
+ <label for="skill-content">SKILL.md <span class="muted">YAML frontmatter (<code>name</code>, <code>description</code>) + body</span></label>
71
86
  <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
72
- <textarea name="content" data-code-editor-target="source" rows="18"><%= @skill_content %></textarea>
87
+ <textarea id="skill-content" name="content" data-code-editor-target="source" rows="18"><%= @skill_content %></textarea>
73
88
  </div>
74
89
  </form>
75
90
 
91
+ <%# A version was in use from its save until the next one: match a turn's
92
+ time against these to see the text that turn ran. %>
93
+ <% if Array(@skill_versions).size > 1 %>
94
+ <details class="drill-section versions">
95
+ <summary>Versions <span class="count"><%= @skill_versions.size %></span></summary>
96
+ <% restore_action = scope ? "/studio/agents/#{Rack::Utils.escape(scope)}/skills/#{Rack::Utils.escape(@selected.to_s)}/restore" : "/studio/skills/#{Rack::Utils.escape(@selected.to_s)}/restore" %>
97
+ <% @skill_versions.each_with_index do |v, k| %>
98
+ <details class="versions">
99
+ <summary class="mono"><%= v["from"] || "?" %> → <%= v["to"] || "now" %></summary>
100
+ <pre><%= v["content"] %></pre>
101
+ <% unless k.zero? %>
102
+ <%# k - 1: the list leads with the current text, the store's history does not %>
103
+ <form method="post" action="<%= restore_action %>">
104
+ <%== csrf_tag %>
105
+ <input type="hidden" name="version" value="<%= k - 1 %>">
106
+ <button type="submit" class="ghost" data-turbo-confirm="Restore this version? The current text stays in the history.">Restore</button>
107
+ </form>
108
+ <% end %>
109
+ </details>
110
+ <% end %>
111
+ </details>
112
+ <% end %>
113
+
76
114
  <% if !new && !scope %>
77
115
  <div class="drill-section">
78
116
  <div class="drill-section-head">
@@ -81,7 +119,7 @@
81
119
  is in the prompt every turn. Both are the AGENT's decision, so one shared skill can be
82
120
  always-on for one and discretionary for another. An "all" allowlist is left untouched.</span>
83
121
  </div>
84
- <form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/agents">
122
+ <form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/agents" data-controller="dirty-guard">
85
123
  <%== csrf_tag %>
86
124
  <% if @agents.empty? %>
87
125
  <p class="muted">No agents configured.</p>
@@ -117,21 +155,17 @@
117
155
  edit the allowlist, which throws the sharing away. %>
118
156
  <% others = @agents.reject { |a| Array((@specialized || {})[@selected]).include?(a.id) } %>
119
157
  <% if others.any? %>
120
- <div class="drill-section">
121
- <div class="drill-section-head">
122
- <h3>Specialize</h3>
123
- <span class="muted">give ONE agent its own version of this skill, seeded from the body above — same name, no fork</span>
124
- </div>
158
+ <details class="drill-section newfile">
159
+ <summary>Specialize for one agent</summary>
160
+ <p class="muted">Create an agent-specific version from the saved shared skill. Save edits above before specializing.</p>
125
161
  <form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/specialize">
126
162
  <%== csrf_tag %>
127
163
  <label>agent
128
- <select name="agent_id" required>
129
- <% others.each do |a| %><option value="<%= a.id %>"><%= a.id %></option><% end %>
130
- </select>
164
+ <%== render("_agent_picker", locals: { name: "agent_id", options: others.map(&:id), value: nil }) %>
131
165
  </label>
132
166
  <div class="actions"><button type="submit" data-turbo-submits-with="Creating…">Specialize for this agent</button></div>
133
167
  </form>
134
- </div>
168
+ </details>
135
169
  <% end %>
136
170
  <% end %>
137
171
  </div>
@@ -1,11 +1,11 @@
1
1
  <header class="page-head">
2
2
  <div>
3
- <nav class="crumbs"><a href="/studio/agents">agents</a> / <span>system files</span></nav>
3
+ <nav class="crumbs"><a href="/studio/agents">agents</a></nav>
4
4
  <h1>System files<span class="sub"><%= @system_files.size %> file(s) · apply to every agent</span></h1>
5
5
  </div>
6
6
  </header>
7
7
 
8
- <p class="muted matrix-hint">GLOBAL prompts/rules: the runtime injects these files <strong>before</strong> each agent's own identity, every turn. Store-backed and versioned (like per-agent prompts). Empty = no global injection (the prompt stays exactly as before).</p>
8
+ <p class="muted matrix-hint">Rules shared by <strong>every agent</strong>, loaded before each agent’s own prompts. Save each file separately; previous versions can be restored below.</p>
9
9
 
10
10
  <% if @system_files.empty? %>
11
11
  <div class="empty">No system files. Create one below (e.g. <code>HOUSE_RULES.md</code>) for a rule that applies to every agent.</div>
@@ -24,8 +24,8 @@
24
24
  <form method="post" action="/studio/system-files" data-controller="dirty-guard">
25
25
  <%== csrf_tag %>
26
26
  <input type="hidden" name="file" value="<%= sf[:name] %>">
27
- <div data-controller="code-editor" data-code-editor-language-value="markdown">
28
- <textarea name="content" data-code-editor-target="source" rows="10"><%= sf[:content] %></textarea>
27
+ <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
28
+ <textarea aria-label="Content of <%= sf[:name] %>" name="content" data-code-editor-target="source" rows="10"><%= sf[:content] %></textarea>
29
29
  </div>
30
30
  <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Saving…">Save file</button></div>
31
31
  </form>
@@ -50,15 +50,15 @@
50
50
  </div>
51
51
  <% end %>
52
52
 
53
- <details class="card newfile">
53
+ <details class="card newfile"<%== " open" if @system_files.empty? %>>
54
54
  <summary>+ new system file</summary>
55
55
  <form method="post" action="/studio/system-files" data-controller="dirty-guard">
56
56
  <%== csrf_tag %>
57
57
  <label>file name
58
58
  <input type="text" name="file" placeholder="HOUSE_RULES.md" required>
59
59
  </label>
60
- <div data-controller="code-editor" data-code-editor-language-value="markdown">
61
- <textarea name="content" data-code-editor-target="source" rows="8" placeholder="# House rules&#10;Every agent must..."></textarea>
60
+ <div data-controller="code-editor" data-code-editor-language-value="markdown" data-code-editor-preview-value="true">
61
+ <textarea aria-label="New system file content" name="content" data-code-editor-target="source" rows="8" placeholder="# House rules&#10;Every agent must..."></textarea>
62
62
  </div>
63
63
  <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Creating…">Create file</button></div>
64
64
  </form>
@@ -1,6 +1,6 @@
1
- <header class="page-head">
1
+ <header class="page-head operation-head">
2
2
  <div>
3
- <nav class="crumbs"><a href="/studio/tasks">tasks</a> / <span>task</span></nav>
3
+ <nav class="crumbs"><a href="/studio/tasks">tasks</a></nav>
4
4
  <h1>Task
5
5
  <span class="copy-id" data-controller="clipboard" data-clipboard-text-value="<%= @task.id %>">
6
6
  <span class="mono"><%= @task.id[0, 16] %></span>
@@ -11,7 +11,7 @@
11
11
  </div>
12
12
  </header>
13
13
 
14
- <div class="card">
14
+ <div class="card operation-summary">
15
15
  <div class="actions">
16
16
  <span class="pill <%= status_class(@task.status) %>"><%= @task.status %></span>
17
17
  <% if @task.session_id %>
@@ -40,6 +40,7 @@
40
40
  <div class="card">
41
41
  <div class="skill-name mono"><%= pa.tool %></div>
42
42
  <div class="skill-desc muted">turn <%= pa.turn %> · requested <%= pa.requested_at %></div>
43
+ <p class="muted">Review the requested arguments before allowing this tool to run.</p>
43
44
  <pre><%= message_content(pa.args) %></pre>
44
45
  <div class="actions">
45
46
  <form method="post" action="/studio/approvals/<%= Rack::Utils.escape(pa.id) %>">
@@ -61,12 +62,7 @@
61
62
  </section>
62
63
  <% end %>
63
64
 
64
- <section class="section">
65
- <div class="section-head"><h2>Command</h2></div>
66
- <div class="card"><pre><%= message_content(@task.command) %></pre></div>
67
- </section>
68
-
69
- <section class="section">
65
+ <section class="section operation-history">
70
66
  <div class="section-head"><h2>Executions <span class="muted">· <%= Array(@task.executions).size %></span></h2></div>
71
67
  <% if Array(@task.executions).empty? %>
72
68
  <div class="empty">No executions yet.</div>
@@ -88,9 +84,14 @@
88
84
  <% end %>
89
85
  </section>
90
86
 
87
+ <details class="card operation-diagnostics">
88
+ <summary>Command</summary>
89
+ <div class="card"><pre><%= message_content(@task.command) %></pre></div>
90
+ </details>
91
+
91
92
  <% if @task.timing && !@task.timing.empty? %>
92
- <section class="section">
93
- <div class="section-head"><h2>Timing <span class="muted">· the turn's latency windows</span></h2></div>
93
+ <details class="card operation-diagnostics">
94
+ <summary>Timing</summary>
94
95
  <div class="card">
95
96
  <ul class="kv">
96
97
  <% @task.timing.each do |key, value| %>
@@ -98,11 +99,11 @@
98
99
  <% end %>
99
100
  </ul>
100
101
  </div>
101
- </section>
102
+ </details>
102
103
  <% end %>
103
104
 
104
- <section class="section">
105
- <div class="section-head"><h2>Latest checkpoint</h2></div>
105
+ <details class="card operation-diagnostics">
106
+ <summary>Latest checkpoint</summary>
106
107
  <% if @checkpoint.nil? %>
107
108
  <div class="empty">No checkpoint recorded.</div>
108
109
  <% else %>
@@ -115,4 +116,33 @@
115
116
  </ul>
116
117
  </div>
117
118
  <% end %>
118
- </section>
119
+ </details>
120
+
121
+ <details class="card operation-diagnostics">
122
+ <% failures = @llm_trace["entries"].count { |entry| entry["status"] == "failed" || entry["exception_class"] } %>
123
+ <summary>Model requests<% if failures.positive? %> <span class="status err"><%= failures %> failed events</span><% end %></summary>
124
+ <p class="muted">Native retries are attempt usage, separate from task executions. Reported cost is diagnostic; aggregate spend uses turn accounting. Tokens: input / output / cache read / cache write / thinking.</p>
125
+ <% if @llm_trace["truncated"] %><p class="muted">Latest 200 events shown; earlier diagnostics were discarded.</p><% end %>
126
+ <% if @llm_trace["entries"].empty? %>
127
+ <div class="empty">No model request diagnostics recorded.</div>
128
+ <% else %>
129
+ <div class="card">
130
+ <table class="grid">
131
+ <thead><tr><th>Event / request</th><th>Operation</th><th>Provider / model</th><th>Request duration</th><th>Outcome</th><th>Tokens</th><th>Reported cost</th></tr></thead>
132
+ <tbody>
133
+ <% @llm_trace["entries"].each do |entry| %>
134
+ <tr data-llm-event="<%= entry["type"] %>">
135
+ <td><%= entry["type"] == "llm_request" ? "Request" : "Attempt usage" %><br><span class="mono"><%= entry["request_id"] %></span></td>
136
+ <td><%= entry["operation"] || "—" %></td>
137
+ <td><%= entry["provider"] || "—" %> / <%= entry["model"] || "—" %></td>
138
+ <td><%= entry["duration_ms"] ? format("%.2f ms", entry["duration_ms"]) : "—" %></td>
139
+ <td><%= entry["status"] || "—" %><% if entry["exception_class"] %> (<%= entry["exception_class"] %>)<% end %></td>
140
+ <td><%= %w[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens].map { |key| entry[key] || "—" }.join(" / ") %></td>
141
+ <td><%= entry["cost"].nil? ? "—" : "$#{entry['cost']}" %></td>
142
+ </tr>
143
+ <% end %>
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+ <% end %>
148
+ </details>
@@ -1,36 +1,53 @@
1
- <header class="page-head">
1
+ <header class="page-head operation-head">
2
2
  <div>
3
- <nav class="crumbs"><span>tasks</span></nav>
4
- <h1>Tasks<span class="sub"><%= @tasks.size %> task(s)</span></h1>
3
+ <%== crumbs_for("Tasks") %>
4
+ <h1>Tasks<span class="sub">most recent first · page <%= @page %></span></h1>
5
5
  </div>
6
6
  <div class="head-actions">
7
7
  <%== agent_filter_form("/studio/tasks", @agent) %>
8
- <a class="btn" href="/studio/approvals">Approvals →</a>
8
+ <a class="btn" href="/studio/approvals">Open approvals</a>
9
9
  </div>
10
10
  </header>
11
11
 
12
- <% if @tasks.empty? %>
12
+ <p class="muted">Open a task to review its execution history, resolve approvals or control a running turn.</p>
13
+
14
+ <% if @tasks.empty? && @page == 1 %>
13
15
  <div class="empty">No tasks yet. A task is born when an agent runs a turn.</div>
16
+ <% elsif @tasks.empty? %>
17
+ <div class="empty">No tasks on this page. <a href="<%= tasks_page_path(1) %>">Back to the most recent</a>.</div>
14
18
  <% else %>
15
19
  <div data-controller="list-filter">
16
20
  <div class="filter-bar">
17
- <input type="search" class="filter-input" placeholder="Filter tasks by id, status or session…" aria-label="Filter tasks"
21
+ <input type="search" class="filter-input" placeholder="Filter this page by id, status or session…" aria-label="Filter tasks"
18
22
  data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
19
23
  <span class="filter-count mono muted" data-list-filter-target="count"></span>
20
24
  </div>
21
- <div class="card">
22
- <ul class="history">
23
- <% @tasks.each do |t| %>
24
- <li data-list-filter-target="item" data-filter-text="<%= t.id %> <%= t.status %> <%= t.session_id %>">
25
- <a href="<%= task_path(t.id) %>"><span class="mono"><%= t.id[0, 12] %></span></a>
26
- <span class="pill <%= status_class(t.status) %>"><%= t.status %></span>
27
- <span class="preview muted">
28
- <% if t.session_id %>session <span class="mono"><%= t.session_id[0, 12] %></span> · <% end %><%= Array(t.executions).size %> execution(s) · updated <%= t.updated_at %>
29
- </span>
30
- </li>
31
- <% end %>
32
- </ul>
25
+ <div class="card operation-table">
26
+ <table class="grid">
27
+ <thead>
28
+ <tr>
29
+ <th>Task</th><th>Status</th><th>session</th><th class="num">executions</th><th class="time">updated</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @tasks.each do |t| %>
34
+ <tr data-list-filter-target="item" data-filter-text="<%= t.id %> <%= t.status %> <%= t.session_id %>">
35
+ <td><a href="<%= task_path(t.id) %>"><span class="mono"><%= t.id[0, 12] %></span></a></td>
36
+ <td><span class="status <%= status_class(t.status) %>"><%= t.status %></span></td>
37
+ <td><% if t.session_id %><a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(t.session_id) %>"><%= t.session_id[0, 12] %></a><% else %><span class="muted">—</span><% end %></td>
38
+ <td class="num"><%= Array(t.executions).size %></td>
39
+ <td class="time"><span title="<%= t.updated_at %>"><%= time_ago(t.updated_at) %></span></td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
33
44
  </div>
34
45
  <div class="empty" data-list-filter-target="empty" hidden>No tasks match your filter.</div>
46
+ <% if @page > 1 || @more %>
47
+ <nav class="pager" aria-label="Task pages">
48
+ <% if @page > 1 %><a class="btn" href="<%= tasks_page_path(@page - 1) %>" rel="prev">← Newer</a><% end %>
49
+ <% if @more %><a class="btn" href="<%= tasks_page_path(@page + 1) %>" rel="next">Older →</a><% end %>
50
+ </nav>
51
+ <% end %>
35
52
  </div>
36
53
  <% end %>