insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,234 @@
1
+ <header class="page-head">
2
+ <div>
3
+ <nav class="crumbs"><span>harvest</span></nav>
4
+ <h1>Harvest<span class="sub"><%= @agent ? "#{@agent}" : "no agent" %></span></h1>
5
+ </div>
6
+ </header>
7
+
8
+ <% if insika[:harvest_store].nil? %>
9
+ <div class="empty">No harvest — the store is not wired.</div>
10
+ <% elsif @agents.empty? %>
11
+ <div class="empty">No agents yet. Add a <span class="mono">harvest:</span> block to an agent's pack to mine its finished traffic.</div>
12
+ <% else %>
13
+ <div class="card">
14
+ <form method="post" action="/studio/harvest" class="actions">
15
+ <%== csrf_tag %>
16
+ <label>
17
+ Agent
18
+ <select name="agent">
19
+ <% @agents.each do |id| %>
20
+ <option value="<%= id %>"<%== ' selected' if id == @agent %>><%= id %></option>
21
+ <% end %>
22
+ </select>
23
+ </label>
24
+ <label title="Ignore the mined markers and re-read the whole window (the ledger dedup still protects).">
25
+ <input type="checkbox" name="full" value="1"> full window
26
+ </label>
27
+ <button type="submit" class="primary">Mine now</button>
28
+ </form>
29
+ <div class="skill-desc muted">
30
+ Reads this agent's finished conversations, asks the configured miner for SKILL
31
+ proposals, and filters them through the negative list and the evidence ledger.
32
+ Nothing is ever applied automatically — a candidate only lands after the double
33
+ gate AND a human approval.
34
+ </div>
35
+ </div>
36
+
37
+ <% if @agents.size > 1 %>
38
+ <div class="filter-bar">
39
+ <% @agents.each do |id| %>
40
+ <a class="btn" href="/studio/harvest?agent=<%= Rack::Utils.escape(id) %>"<%== ' aria-current="page"' if id == @agent %>><%= id %></a>
41
+ <% end %>
42
+ </div>
43
+ <% end %>
44
+
45
+ <%# The human's inbox. %>
46
+ <% if @candidates.any? %>
47
+ <h2>Awaiting approval<span class="sub">the human's answer</span></h2>
48
+ <% @candidates.each do |cand| %>
49
+ <div class="card">
50
+ <div class="skill-desc">
51
+ <span class="pill ok">gated</span>
52
+ <span class="mono"><%= cand.name %></span>
53
+ <span class="muted">· <%= cand.description %></span>
54
+ </div>
55
+ <% Array(cand.triggers).each do |t| %>
56
+ <span class="pill"><%= t %></span>
57
+ <% end %>
58
+ <div class="skill-desc muted">
59
+ store <span class="mono"><%= cand.agent %></span> · proposed by
60
+ <span class="mono"><%= cand.proposer %></span> · <%= (cand.eval_gate || {})["passed_cases"] %>/<%= (cand.eval_gate || {})["cases"] %> case(s) no regression
61
+ · conversion <%= (cand.conversion_gate || {})["current"] %> vs <%= (cand.conversion_gate || {})["baseline"] %> (threshold <%= (cand.conversion_gate || {})["threshold"] %>)
62
+ </div>
63
+ <% if (excerpt = candidate_excerpt(cand)).any? %>
64
+ <details class="skill-desc">
65
+ <summary>evidence excerpt</summary>
66
+ <% excerpt.each do |sid, idx, text| %>
67
+ <div class="preview muted">
68
+ <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid %></a> [<%= idx %>]: <%= text %>
69
+ </div>
70
+ <% end %>
71
+ </details>
72
+ <% end %>
73
+ <% Array(cand.origin).each do |sid| %>
74
+ <a class="mono muted" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid %></a>
75
+ <% end %>
76
+ <form method="post" action="/studio/harvest/promote" class="actions">
77
+ <%== csrf_tag %>
78
+ <input type="hidden" name="agent" value="<%= cand.agent %>">
79
+ <input type="hidden" name="candidate_id" value="<%= cand.id %>">
80
+ <label>
81
+ Note
82
+ <input type="text" name="note" placeholder="why (optional)">
83
+ </label>
84
+ <button type="submit" class="primary">Promote</button>
85
+ <button type="submit" class="ghost" formaction="/studio/harvest/reject">Reject</button>
86
+ </form>
87
+ <div class="skill-desc muted">
88
+ Promoting snapshots the pre-promotion state, writes the skill into this store's
89
+ scope, enables it on the allowlist and appends the promotion log row. Rollback
90
+ is the one-click mirror.
91
+ </div>
92
+ </div>
93
+ <% end %>
94
+ <% end %>
95
+
96
+ <%# Gated-but-blocked: the ruler's hole, named. %>
97
+ <% if @blocked.any? %>
98
+ <h2>Blocked<span class="sub">gated, waiting on the ruler</span></h2>
99
+ <div class="card">
100
+ <ul class="history">
101
+ <% @blocked.each do |cand| %>
102
+ <li>
103
+ <span class="pill err">blocked</span>
104
+ <span class="mono"><%= cand.name %></span>
105
+ <span class="preview muted">
106
+ <% reason = (cand.conversion_gate || {})["reason"] %>
107
+ <% if reason == "no_frozen_baseline" %>
108
+ the store has no frozen funnel baseline — freeze it first on the
109
+ <a href="/studio/funnel">Funnel page</a>
110
+ <% elsif reason %>
111
+ <%= reason %>
112
+ <% else %>
113
+ <%= (cand.eval_gate || {})["reason"] %>
114
+ <% end %>
115
+ </span>
116
+ </li>
117
+ <% end %>
118
+ </ul>
119
+ </div>
120
+ <% end %>
121
+
122
+ <%# Mined, not yet gated. %>
123
+ <% if @pending_gates.any? %>
124
+ <h2>Pending<span class="sub">mined, awaiting the double gate</span></h2>
125
+ <div class="card">
126
+ <ul class="history">
127
+ <% @pending_gates.each do |cand| %>
128
+ <li>
129
+ <span class="pill">pending</span>
130
+ <span class="mono"><%= cand.name %></span>
131
+ <span class="preview muted"><%= cand.description %></span>
132
+ <form method="post" action="/studio/harvest/gate" class="actions inline">
133
+ <%== csrf_tag %>
134
+ <input type="hidden" name="agent" value="<%= cand.agent %>">
135
+ <input type="hidden" name="candidate_id" value="<%= cand.id %>">
136
+ <button type="submit" class="primary">Gate it</button>
137
+ <button type="submit" class="ghost" formaction="/studio/harvest/reject">Reject</button>
138
+ </form>
139
+ </li>
140
+ <% end %>
141
+ </ul>
142
+ </div>
143
+ <% end %>
144
+
145
+ <%# The promotion log — the single ledger, with the rollback mirror. %>
146
+ <% if @promotions.any? %>
147
+ <h2>Promoted<span class="sub">the append-only log</span></h2>
148
+ <div class="card">
149
+ <ul class="history">
150
+ <% @promotions.each do |row| %>
151
+ <li>
152
+ <% if row.rolled_back_at %>
153
+ <span class="pill err">rolled back</span>
154
+ <% else %>
155
+ <span class="pill ok">promoted</span>
156
+ <% end %>
157
+ <span class="mono"><%= row.skill %></span>
158
+ <span class="preview muted">
159
+ <%= row.approver %> · <%= row.at %>
160
+ <% if row.rolled_back_at %>· rolled back <%= row.rolled_back_at %><% end %>
161
+ </span>
162
+ <% unless row.rolled_back_at %>
163
+ <form method="post" action="/studio/harvest/rollback" class="actions inline">
164
+ <%== csrf_tag %>
165
+ <input type="hidden" name="agent" value="<%= row.agent %>">
166
+ <input type="hidden" name="snapshot_ref" value="<%= row.snapshot_ref %>">
167
+ <input type="text" name="reason" placeholder="why (optional)">
168
+ <button type="submit" class="ghost">Rollback</button>
169
+ </form>
170
+ <% end %>
171
+ </li>
172
+ <% end %>
173
+ </ul>
174
+ </div>
175
+ <% end %>
176
+
177
+ <%# The negative list block. %>
178
+ <% if @negative && @negative.rules.any? %>
179
+ <h2>Negative list<span class="sub">the versioned seed, engine-applied</span></h2>
180
+ <div class="card">
181
+ <ul class="history">
182
+ <% @negative.rules.each do |rule| %>
183
+ <li>
184
+ <span class="pill"><%= rule.rule %></span>
185
+ <span class="preview muted"><%= rule.pattern %><%= " — #{rule.note}" if presence(rule.note) %></span>
186
+ <span class="preview muted">rejected: <%= negative_rule_counts[rule.rule] || 0 %></span>
187
+ </li>
188
+ <% end %>
189
+ </ul>
190
+ <div class="skill-desc muted">
191
+ The list is pack data on the profile (hot-editable); the versioned
192
+ rules file seeds it via
193
+ <span class="mono">insika harvest:negative import --agent ID --file F</span>.
194
+ </div>
195
+ </div>
196
+ <% end %>
197
+
198
+ <%# The ruler block. %>
199
+ <% if @criterion %>
200
+ <h2>The conversion criterion<span class="sub">frozen before the first promotion</span></h2>
201
+ <div class="card">
202
+ <div class="skill-desc">
203
+ <span class="pill">metric <%= @criterion.rule.metric %></span>
204
+ <span class="pill">window <%= @criterion.rule.window %></span>
205
+ <span class="pill">not worse than <%= @criterion.rule.threshold %></span>
206
+ <span class="mono muted"><%= @criterion.sha %></span>
207
+ </div>
208
+ <div class="skill-desc muted">
209
+ The ruler is the frozen criterion file, read-only here; the
210
+ store's frozen baseline lives on the <a href="/studio/funnel">Funnel page</a>. A
211
+ store without one can mine, but the gate parks its candidates.
212
+ </div>
213
+ </div>
214
+ <% end %>
215
+
216
+ <% if @runs.any? %>
217
+ <h2>Runs<span class="sub">most recent first</span></h2>
218
+ <div class="card">
219
+ <ul class="history">
220
+ <% @runs.each do |run| %>
221
+ <li>
222
+ <span class="pill <%= run.status == 'failed' ? 'err' : 'ok' %>"><%= run.status %></span>
223
+ <span class="mono"><%= run.id[0, 12] %></span>
224
+ <span class="preview muted">
225
+ <%= run.candidates %> candidate(s) ·
226
+ <% if run.cost && run.cost["spent"].to_i.positive? %><%= run.cost["spent"] %> tokens · <% end %>
227
+ <%= run.started_at %>
228
+ </span>
229
+ </li>
230
+ <% end %>
231
+ </ul>
232
+ </div>
233
+ <% end %>
234
+ <% end %>
@@ -6,6 +6,7 @@
6
6
  <h1>Overview<span class="sub">at a glance</span></h1>
7
7
  </div>
8
8
  <div class="head-actions">
9
+ <%== agent_filter_form("/studio/home", @agent) %>
9
10
  <span class="pill <%= @active_now.positive? ? "ok online" : "info" %> plain"><%= @active_now %> active now</span>
10
11
  <a class="btn primary" href="/studio/playground">Open playground →</a>
11
12
  </div>
@@ -58,7 +59,7 @@
58
59
  <% @recent.each do |s| %>
59
60
  <li>
60
61
  <a href="/studio/sessions/<%= Rack::Utils.escape(s.id) %>">
61
- <span class="avatar sm"><%= s.id[0, 2].upcase %></span>
62
+ <span class="avatar sm <%= avatar_class(s.id) %>"><%= s.id[0, 2].upcase %></span>
62
63
  <span class="convo-main">
63
64
  <span class="convo-id mono"><%= s.id[0, 20] %></span>
64
65
  <span class="convo-sub"><%= Array(s.messages).size %> msg · <%= time_ago(s.updated_at) %></span>
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="csp-nonce" content="<%= csp_nonce %>">
7
7
  <title>Insika Studio</title>
8
+ <link rel="icon" type="image/svg+xml" href="<%= asset_path("favicon.svg") %>">
8
9
  <link rel="stylesheet" href="<%= asset_path("application.css") %>">
9
10
  <script type="module" src="<%= asset_path("application.js") %>" defer></script>
10
11
  </head>
@@ -0,0 +1,147 @@
1
+ <header class="page-head">
2
+ <div>
3
+ <nav class="crumbs"><span>operate</span></nav>
4
+ <h1>Parity<span class="sub">the shadow experiment</span></h1>
5
+ </div>
6
+ <div class="head-actions">
7
+ <%== agent_filter_form("/studio/parity", @agent) %>
8
+ </div>
9
+ </header>
10
+
11
+ <% if @criterion.nil? %>
12
+ <div class="empty">
13
+ No shadow channel is registered. Set <span class="mono">INSIKA_RELAY_SHADOW=1</span> and
14
+ restart — this page appears only when a channel is actually running in shadow.
15
+ </div>
16
+ <% elsif @pairs.empty? %>
17
+ <div class="empty">
18
+ No pairs yet. A mirrored exchange records a pair the moment our turn ends; the
19
+ incumbent's half lands when the mirror reports its reply
20
+ (<span class="mono">POST /channels/relay/shadow-reply</span>).
21
+ </div>
22
+ <% else %>
23
+ <% r = @report %>
24
+ <div class="card">
25
+ <div class="skill-desc">
26
+ <span class="pill <%= { pass: 'ok', fail: 'err', insufficient: 'warn', invalid: 'err' }.fetch(r.verdict, 'info') %>"><%= r.verdict %></span>
27
+ <span class="muted"><%= r.reason %></span>
28
+ </div>
29
+ <% sha_check = r.checks.find { |c| c.id == :criterion_sha } %>
30
+ <div class="skill-desc">
31
+ <span class="mono"><%= r.criterion_sha %></span>
32
+ <% if sha_check&.met %>
33
+ <span class="pill ok">frozen · matches every pair in the window</span>
34
+ <% else %>
35
+ <span class="pill err">NOT VERIFIED — the window's pairs do not all carry this frozen hash</span>
36
+ <% end %>
37
+ </div>
38
+ </div>
39
+
40
+ <h2>Checks<span class="sub">the arithmetic, not the conclusion</span></h2>
41
+ <div class="card">
42
+ <table class="grid">
43
+ <thead><tr><th>check</th><th>required</th><th>actual</th><th></th></tr></thead>
44
+ <tbody>
45
+ <% r.checks.each do |c| %>
46
+ <tr>
47
+ <td class="mono"><%= c.id %></td>
48
+ <td><%= c.required %></td>
49
+ <td><% if c.actual.is_a?(Hash) %><%= c.actual.map { |k, v| "#{k}: #{v}" }.join(" · ") %><% else %><%= c.actual %><% end %></td>
50
+ <td><span class="pill <%= c.met ? 'ok' : 'err' %>"><%= c.met ? "met" : "no" %></span></td>
51
+ </tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+
57
+ <h2>Pairs / day<span class="sub">floor <%= @criterion.rule.pairs_per_day %> · a gap day invalidates the window</span></h2>
58
+ <div class="card">
59
+ <table class="grid">
60
+ <thead><tr><th>date</th><th>pairs</th><th>decided</th><th></th></tr></thead>
61
+ <tbody>
62
+ <% r.daily.each do |d| %>
63
+ <tr>
64
+ <td class="mono"><%= d[:date] %></td>
65
+ <td><%= d[:pairs] %></td>
66
+ <td><%= d[:decided] %></td>
67
+ <td><% if d[:floor].zero? %><span class="muted">partial day — reported, not required</span><% elsif d[:pairs] >= d[:floor] %><span class="pill ok">met</span><% else %><span class="pill err">below floor</span><% end %></td>
68
+ </tr>
69
+ <% end %>
70
+ </tbody>
71
+ </table>
72
+ </div>
73
+
74
+ <% if r.counts %>
75
+ <h2>Per store<span class="sub">a store blocks its own cut even when the aggregate passes</span></h2>
76
+ <div class="card">
77
+ <table class="grid">
78
+ <thead><tr><th>store</th><th>decided</th><th>win-or-tie</th><th>worse</th><th>meets floor</th></tr></thead>
79
+ <tbody>
80
+ <% r.per_agent.each do |agent, stats| %>
81
+ <tr>
82
+ <td class="mono"><%= agent %></td>
83
+ <td><%= stats[:decided] %></td>
84
+ <td><%= stats[:win_or_tie] %></td>
85
+ <td><%= stats[:worse] %></td>
86
+ <td><% if stats[:meets].nil? %><span class="muted">below per-store minimum</span><% elsif stats[:meets] %><span class="pill ok">yes</span><% else %><span class="pill err">no</span><% end %></td>
87
+ </tr>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+ </div>
92
+
93
+ <h2>Judge agreement<span class="sub">split / unknown / order-dependent</span></h2>
94
+ <div class="card">
95
+ <div class="skill-desc">
96
+ <span class="pill <%= r.counts[:split].positive? ? 'warn' : 'ok' %>"><%= r.counts[:split] %> split</span>
97
+ <span class="pill <%= r.counts[:unknown].positive? ? 'warn' : 'ok' %>"><%= r.counts[:unknown] %> unknown</span>
98
+ <span class="pill <%= @agreement[:order_dependent].positive? ? 'warn' : 'ok' %>"><%= @agreement[:order_dependent] %> flipped with order</span>
99
+ <span class="muted">of <%= r.counts[:judged] %> judged pairs</span>
100
+ </div>
101
+ </div>
102
+ <% end %>
103
+
104
+ <h2>Judge a batch</h2>
105
+ <div class="card">
106
+ <form method="post" action="/studio/parity" class="actions">
107
+ <%== csrf_tag %>
108
+ <label>
109
+ Agent (all stores when blank)
110
+ <input type="text" name="agent" placeholder="agent-id">
111
+ </label>
112
+ <label>
113
+ Limit
114
+ <input type="number" name="limit" value="50" min="1" max="200">
115
+ </label>
116
+ <button type="submit" class="primary">Judge <%= @pending %> complete pair(s)</button>
117
+ </form>
118
+ <div class="skill-desc muted">
119
+ Cost: <span class="mono"><%= @judge_cost %></span> provider calls
120
+ (<%= @pending %> pairs × <%= @criterion.rule.min_judge_models %> judges × 2 orders).
121
+ <% if Array((@judge_models || {})["judges"]).empty? %>
122
+ <span class="pill err">no judges configured in settings['evals'] — the press will refuse</span>
123
+ <% end %>
124
+ </div>
125
+ </div>
126
+
127
+ <h2>Recent pairs<span class="sub">inbound · incumbent · insika</span></h2>
128
+ <div class="card">
129
+ <ul class="history">
130
+ <% @pairs.each do |p| %>
131
+ <li>
132
+ <span class="pill <%= { open: 'warn', complete: 'info', silent: 'info', judged: 'ok', incomplete: 'err' }.fetch(p.status, 'info') %>"><%= p.status %></span>
133
+ <span class="mono"><%= p.id[0, 12] %></span>
134
+ <% if p.agent %><span class="mono"><%= p.agent %></span><% end %>
135
+ <% if p.outcome %><span class="pill"><%= p.outcome %></span><% end %>
136
+ <% if p.session_id %><a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(p.session_id) %>">session →</a><% end %>
137
+ <div class="skill-desc muted"><strong>customer:</strong> <%= p.inbound.to_s %></div>
138
+ <div class="skill-desc"><strong>incumbent:</strong> <%= p.incumbent_reply.to_s %></div>
139
+ <div class="skill-desc"><strong>insika:</strong> <%= p.insika_reply.to_s %></div>
140
+ <% if p.verdict.is_a?(Hash) && presence(p.verdict["reason"]) %>
141
+ <div class="skill-desc muted">why: <%= p.verdict["reason"] %></div>
142
+ <% end %>
143
+ </li>
144
+ <% end %>
145
+ </ul>
146
+ </div>
147
+ <% end %>
@@ -15,7 +15,13 @@
15
15
  </select>
16
16
  </label>
17
17
  <label class="chat-field">session
18
- <input type="text" name="session_id" value="<%= @session_id %>" placeholder="empty = new conversation">
18
+ <input type="text" name="session_id" value="<%= @session_id %>" placeholder="empty = new conversation"
19
+ list="recent-sessions" autocomplete="off">
20
+ <datalist id="recent-sessions">
21
+ <% Array(@recent).each do |s| %>
22
+ <option value="<%= s.id %>" label="<%= [time_ago(s.updated_at), presence(session_preview(s).to_s[0, 60])].compact.join(" · ") %>">
23
+ <% end %>
24
+ </datalist>
19
25
  </label>
20
26
  <span class="pill info chat-status" data-live-transcript-target="status">disconnected</span>
21
27
  <details class="pin chat-pin">
@@ -39,7 +39,7 @@
39
39
  </div>
40
40
  <% end %>
41
41
 
42
- <%# RFC-0013 §3.6 — the review IS the product. A gated proposal shows the exact
42
+ <%# The review IS the product. A gated proposal shows the exact
43
43
  edit, what it claims to fix, and how it scored against the accepted baseline,
44
44
  because approving is a decision and a decision needs those three things. %>
45
45
  <% if @proposal %>
@@ -51,7 +51,7 @@
51
51
  <span class="mono"><%= @proposal.gate["passed_cases"] %>/<%= @proposal.gate["cases"] %> case(s)</span>
52
52
  <span class="muted">no regression against <%= @proposal.gate["baseline_cases"] %> baselined case(s)</span>
53
53
  <span class="muted">· proposed by <span class="mono"><%= cand["proposer"] %></span></span>
54
- <%# RFC-0013 §3.9 — when a PANEL ran, the operator is looking at the winner of
54
+ <%# When a PANEL ran, the operator is looking at the winner of
55
55
  several, and which ones it beat is part of the decision. %>
56
56
  <% if @proposal.panel.size > 1 %>
57
57
  <span class="muted">· best of <%= @proposal.panel.size %> candidate(s)</span>
@@ -125,7 +125,7 @@
125
125
  <% else %>
126
126
  <h2>Findings<span class="sub"><%= @run.findings.size %> · <%= @run.status %><% if @run.excluded.to_i.positive? %> · <%= @run.excluded %> turn(s) excluded<% end %> · <%= @run.finished_at || @run.started_at %></span></h2>
127
127
 
128
- <%# RFC-0013 §3.4 — the model writes the candidate, the gate scores it. Offered on
128
+ <%# The model writes the candidate, the gate scores it. Offered on
129
129
  a finished report and only when nothing is already awaiting an answer: a second
130
130
  proposal while one is pending would ask the operator to review two edits to the
131
131
  same file that were never scored together. %>
@@ -147,7 +147,7 @@
147
147
  </div>
148
148
  <% end %>
149
149
 
150
- <%# RFC-0013 §3.6 — `mode: auto_apply` changed this agent's files with nobody in
150
+ <%# `mode: auto_apply` changed this agent's files with nobody in
151
151
  the loop, so the page owes the operator exactly three things: what changed, why,
152
152
  and where to undo it. The write versioned, so History IS the undo. %>
153
153
  <% if @run.status == :applied && (@run.decision || {})["by"] == "auto_apply" %>
@@ -12,6 +12,82 @@
12
12
  <a class="btn" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>">Continue in playground →</a>
13
13
  </header>
14
14
 
15
+ <% briefing = @session.respond_to?(:briefing) ? (@session.briefing || {}) : {} %>
16
+ <% fields = briefing["fields"] || {} %>
17
+ <% next_step = briefing["next_step"] %>
18
+ <% if fields.any? || next_step %>
19
+ <div class="card">
20
+ <div class="trace-head">
21
+ <h2>Briefing</h2>
22
+ <span class="muted">this conversation's working state — read-only, written by the agent</span>
23
+ </div>
24
+ <% fields.sort.each do |name, value| %>
25
+ <div class="kv"><span><%= name %></span><div><%= value %></div></div>
26
+ <% end %>
27
+ <% if next_step %>
28
+ <div class="kv"><span>next step</span><div><%= next_step %></div></div>
29
+ <% end %>
30
+ </div>
31
+ <% end %>
32
+
33
+ <% ctx_traces = @context_traces || [] %>
34
+ <% unless ctx_traces.empty? %>
35
+ <div class="card tooltrace">
36
+ <div class="trace-head">
37
+ <h2>Context</h2>
38
+ <span class="muted">tokens by category per turn (estimated) — no content stored</span>
39
+ </div>
40
+ <% ctx_traces.each do |c| %>
41
+ <div class="trace-turn">
42
+ <div class="turn-label">turn <%= c["turn"] %> · <%= c["at"] %></div>
43
+ <%# Skills injected by context (triggers/skills_eager) leave NO tool call in
44
+ the transcript, so this card is the only record that they were active.
45
+ The names live in the body, but the count belongs in the SUMMARY: the
46
+ card is collapsed, and "was the skill on?" is the question an operator
47
+ asks on every single turn. %>
48
+ <% skills = Array((c["categories"] || {}).dig("skilltrigger", "labels")).uniq %>
49
+ <details class="trace-item ok">
50
+ <summary>
51
+ <span class="badge"><%= c["used"] %> / <%= c["cap"] %></span>
52
+ <strong>context</strong>
53
+ <%# the prefix cache-hit of the turn + the first block
54
+ whose bytes changed vs the previous turn (PII-free category id). %>
55
+ <% cache = c["cache"] %>
56
+ <% if cache && cache["hit_pct"] %><span class="badge"><%= cache["hit_pct"] %>% cached</span><% end %>
57
+ <% if cache && cache["invalidation_reason"] %>
58
+ <span class="lat">broke: <%= cache["invalidation_reason"] %></span>
59
+ <% end %>
60
+ <% unless skills.empty? %>
61
+ <span class="badge">skills <%= skills.length %></span>
62
+ <% end %>
63
+ <% unless Array(c["evicted"]).empty? %>
64
+ <span class="lat">evicted: <%= Array(c["evicted"]).join(", ") %></span>
65
+ <% end %>
66
+ </summary>
67
+ <% (c["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
68
+ <div class="kv"><span><%= name %></span>
69
+ <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %><%== " · identity" if cat["layer"] == "identity" %>
70
+ <%# WHICH skills the turn injected AND WHY — the only after-the-fact %>
71
+ <%# record of activation once the bodies arrive by context instead of a %>
72
+ <%# tool call. The reason is the half the operator was missing: a name %>
73
+ <%# alone does not say whether they triggered it or the agent always has it. %>
74
+ <% labels = Array(cat["labels"]).uniq %>
75
+ <% unless labels.empty? %>
76
+ <br><span class="lat"><%= labels.map { |l| skill_label(l) }.join(", ") %></span>
77
+ <% end %>
78
+ </div>
79
+ </div>
80
+ <% end %>
81
+ <% tools = c["tools"] || {} %>
82
+ <div class="kv"><span>tools</span>
83
+ <div><%= tools["tokens"] %> tokens · <%= tools["count"] %> tool schema(s)</div>
84
+ </div>
85
+ </details>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
90
+
15
91
  <% traces = @tool_traces || {} %>
16
92
  <% unless traces.empty? %>
17
93
  <div class="card tooltrace">
@@ -49,14 +125,68 @@
49
125
  </div>
50
126
  <% end %>
51
127
 
128
+ <%# the session's stage history — outcomes folded to the agent's
129
+ declared stages (unmapped kinds render the raw outcome: the hole is visible).
130
+ Without a declaration the block renders the raw outcomes. %>
131
+ <% history = @stage_history || {} %>
132
+ <% unless history.empty? %>
133
+ <div class="card">
134
+ <div class="trace-head">
135
+ <h2>Stage history</h2>
136
+ <span class="muted">
137
+ <% if history[:declaration] %>
138
+ <%= history[:declaration].stages.join(" → ") %> · outcome funnel
139
+ <% else %>
140
+ no funnel declared — raw outcomes
141
+ <% end %>
142
+ </span>
143
+ </div>
144
+ <div class="kv">
145
+ <% history[:rows].each do |row| %>
146
+ <% if row[:stage] %>
147
+ <span><%= row[:outcome] %></span>
148
+ <div>→ <strong><%= row[:stage] %></strong> <span class="lat"><%= row[:at] %></span></div>
149
+ <% else %>
150
+ <span><%= row[:outcome] %></span>
151
+ <div><span class="lat">not a declared stage</span> <span class="lat"><%= row[:at] %></span></div>
152
+ <% end %>
153
+ <% end %>
154
+ </div>
155
+ </div>
156
+ <% end %>
157
+
52
158
  <div class="card transcript">
53
159
  <% if Array(@session.messages).empty? %>
54
160
  <div class="empty">This session has no messages.</div>
55
161
  <% else %>
56
162
  <div class="thread">
57
- <%# `prev` is what makes a STEERED message visible: a customer message that lands
58
- right after a tool result arrived mid-run (RFC-0015). See _message.erb. %>
59
- <% Array(@session.messages).each_with_index do |m, i| %><%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %><% end %>
163
+ <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
164
+ so its body is a persisted `role: tool` message. A skill injected by CONTEXT
165
+ (triggers/skills_eager) is part of the system prompt — never a message — so
166
+ the thread had no trace of it at all. Synthesized from the context trace so
167
+ both activation paths read the same way in the transcript.
168
+
169
+ Placed by TIME, not by counting turns: every message of a turn is persisted
170
+ together at the end of it, and the context stage always runs before that, so
171
+ each entry lands at the top of its own turn. Ordinal counting would drift on
172
+ a steered message (one that joins a turn already running). A message with no
173
+ `at` (older sessions) simply flushes nothing. %>
174
+ <% pending = (@context_traces || [])
175
+ .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
176
+ .sort_by { |c| c["at"].to_s } %>
177
+ <% Array(@session.messages).each_with_index do |m, i| %>
178
+ <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
179
+ <% pending.shift %>
180
+ <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
181
+ <details class="toolcard skill">
182
+ <summary><span class="arrow">↑</span><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
183
+ <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
184
+ </details>
185
+ <% end %>
186
+ <%# `prev` is what makes a STEERED message visible: a customer message that lands
187
+ right after a tool result arrived mid-run. See _message.erb. %>
188
+ <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %>
189
+ <% end %>
60
190
  </div>
61
191
  <% end %>
62
192
  </div>