insika 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -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">
@@ -12,6 +12,24 @@
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
+
15
33
  <% ctx_traces = @context_traces || [] %>
16
34
  <% unless ctx_traces.empty? %>
17
35
  <div class="card tooltrace">
@@ -32,6 +50,13 @@
32
50
  <summary>
33
51
  <span class="badge"><%= c["used"] %> / <%= c["cap"] %></span>
34
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 %>
35
60
  <% unless skills.empty? %>
36
61
  <span class="badge">skills <%= skills.length %></span>
37
62
  <% end %>
@@ -41,7 +66,7 @@
41
66
  </summary>
42
67
  <% (c["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
43
68
  <div class="kv"><span><%= name %></span>
44
- <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %>
69
+ <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %><%== " · identity" if cat["layer"] == "identity" %>
45
70
  <%# WHICH skills the turn injected AND WHY — the only after-the-fact %>
46
71
  <%# record of activation once the bodies arrive by context instead of a %>
47
72
  <%# tool call. The reason is the half the operator was missing: a name %>
@@ -100,6 +125,36 @@
100
125
  </div>
101
126
  <% end %>
102
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
+
103
158
  <div class="card transcript">
104
159
  <% if Array(@session.messages).empty? %>
105
160
  <div class="empty">This session has no messages.</div>
@@ -39,7 +39,9 @@
39
39
  <label>max_retries<input type="text" name="max_retries" value="<%= @settings["max_retries"] %>" inputmode="numeric"></label>
40
40
  <label>turn_timeout (s)<input type="text" name="turn_timeout" value="<%= @settings["turn_timeout"] %>" inputmode="numeric"></label>
41
41
  <label>tool_timeout (s)<input type="text" name="tool_timeout" value="<%= @settings["tool_timeout"] %>" inputmode="numeric"></label>
42
+ <label>Memory TTL (days)<input type="text" name="memory_ttl_days" value="<%= @settings["memory_ttl_days"] %>" inputmode="numeric" placeholder="blank = off"></label>
42
43
  </div>
44
+ <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>
43
45
  </form>
44
46
  </div>
45
47
 
@@ -39,7 +39,6 @@
39
39
  <% new = @selected == "" %>
40
40
  <div class="drill-pane-head">
41
41
  <div class="title">
42
- <nav class="crumbs"><a href="/studio/skills">skills</a> / <span><%= new ? "new" : @selected %></span><% if scope %> / <span>for <%= scope %></span><% end %></nav>
43
42
  <strong class="mono"><%= new ? "New skill" : @selected %><% if scope %> <span class="pill warn">specialized for <%= scope %></span><% end %></strong>
44
43
  </div>
45
44
  <button type="submit" form="skill-form" class="btn primary" data-turbo-submits-with="Saving…">Save skill</button>
@@ -5,7 +5,7 @@
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 BIA'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">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>
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>
@@ -88,6 +88,19 @@
88
88
  <% end %>
89
89
  </section>
90
90
 
91
+ <% 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>
94
+ <div class="card">
95
+ <ul class="kv">
96
+ <% @task.timing.each do |key, value| %>
97
+ <li><span class="k mono"><%= key %></span><span class="v mono"><%= value %></span></li>
98
+ <% end %>
99
+ </ul>
100
+ </div>
101
+ </section>
102
+ <% end %>
103
+
91
104
  <section class="section">
92
105
  <div class="section-head"><h2>Latest checkpoint</h2></div>
93
106
  <% if @checkpoint.nil? %>
@@ -3,7 +3,10 @@
3
3
  <nav class="crumbs"><span>tasks</span></nav>
4
4
  <h1>Tasks<span class="sub"><%= @tasks.size %> task(s)</span></h1>
5
5
  </div>
6
- <a class="btn" href="/studio/approvals">Approvals →</a>
6
+ <div class="head-actions">
7
+ <%== agent_filter_form("/studio/tasks", @agent) %>
8
+ <a class="btn" href="/studio/approvals">Approvals →</a>
9
+ </div>
7
10
  </header>
8
11
 
9
12
  <% if @tasks.empty? %>
@@ -47,7 +47,6 @@
47
47
  <% a = @sel_agent %>
48
48
  <div class="drill-pane-head">
49
49
  <div class="title">
50
- <nav class="crumbs"><a href="/studio/tools">tools</a> / <span><%= a.id %></span></nav>
51
50
  <strong class="mono"><%= a.id %></strong>
52
51
  </div>
53
52
  <button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
@@ -33,7 +33,7 @@ module Insika
33
33
  }.freeze
34
34
 
35
35
  Task = Data.define(:id, :status, :command, :session_id, :executions,
36
- :mailbox_state, :created_at, :updated_at)
36
+ :mailbox_state, :timing, :created_at, :updated_at)
37
37
  Execution = Data.define(:attempt, :started_at, :finished_at, :outcome, :error)
38
38
 
39
39
  def initialize(store:)
@@ -131,6 +131,18 @@ module Insika
131
131
  to_task(record)
132
132
  end
133
133
 
134
+ # writes the turn's timing breakdown onto the task record, once,
135
+ # when the turn completes. Best-effort by the caller's contract — a failure
136
+ # here must never re-fail an already-committed turn. `timing` is DATA (string
137
+ # keys, JSON types); deep_stringify makes a symbol-keyed to_h durable.
138
+ def record_timing(id, timing)
139
+ record = fetch!(id)
140
+ record["timing"] = deep_stringify(timing)
141
+ record["updated_at"] = timestamp
142
+ @store.set(SCOPE, key_for(id), record)
143
+ to_task(record)
144
+ end
145
+
134
146
  # (`collect`): appends a fragment to a task's message while it is
135
147
  # still waiting at the door. -> Task.
136
148
  #
@@ -185,6 +197,13 @@ module Insika
185
197
  end
186
198
  end
187
199
 
200
+ # -> true | false (did it exist?). For Retention (WS8) — a purged session's
201
+ # tasks go with it, terminal tasks only (a live :running task must never
202
+ # be deleted under its own fiber).
203
+ def delete(id)
204
+ @store.delete(SCOPE, key_for(id))
205
+ end
206
+
188
207
  private
189
208
 
190
209
  def key_for(id)
@@ -228,6 +247,7 @@ module Insika
228
247
  session_id: record["session_id"],
229
248
  executions: record["executions"].map { |e| to_execution(e) },
230
249
  mailbox_state: record["mailbox_state"],
250
+ timing: record["timing"],
231
251
  created_at: record["created_at"],
232
252
  updated_at: record["updated_at"]
233
253
  )