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,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#15161b"/>
3
+ <circle cx="32" cy="32" r="17" fill="#e8b23a" opacity=".18"/>
4
+ <circle cx="32" cy="32" r="12" fill="#e8b23a"/>
5
+ <circle cx="32" cy="32" r="4.5" fill="#15161b"/>
6
+ </svg>
@@ -7,6 +7,10 @@ module Studio
7
7
  # per-request state (`@agent`) exactly as before. Pure extraction — the bodies
8
8
  # moved verbatim from app.rb; app_spec's POST cases are the safety net.
9
9
  module Forms
10
+ # The agent config form OWNS every rendered field: a save reflects the
11
+ # whole form state, so a blank field is a deliberate "clear to default",
12
+ # never a carry-over. The one exception is keys the form cannot express —
13
+ # those ride the `carry` merge below (the guardrails corpora precedent).
10
14
  def config_patch(r)
11
15
  limits = @agent.limits.dup
12
16
  # Always present via DEFAULT_LIMITS (tool_concurrency defaults to 1 = serial),
@@ -34,14 +38,260 @@ module Studio
34
38
  limits: limits,
35
39
  params: params_patch(r),
36
40
  model_policy: model_policy_patch(r),
37
- guardrails: guardrails_patch(r)
41
+ guardrails: guardrails_patch(r),
42
+ # Every remaining AgentProfile field is editable here — the pack
43
+ # import is one door, this form is the other (hot, no restart).
44
+ grounding: grounding_patch(r),
45
+ funnel: funnel_patch(r),
46
+ followup: followup_patch(r),
47
+ distill: distill_patch(r),
48
+ harvest: harvest_patch(r),
49
+ refinement: refinement_patch(r),
50
+ budget: budget_patch(r),
51
+ reliability: reliability_patch(r),
52
+ routes: json_patch(r, "routes"),
53
+ outputs: json_patch(r, "outputs"),
54
+ metadata: json_patch(r, "metadata") || {},
55
+ alerts: alerts_patch(r),
56
+ edge_stream: edge_stream_patch(r),
57
+ stuck_signal: r.params["stuck_signal"] == "1",
58
+ prompt_caching: r.params["prompt_caching"] == "1",
59
+ # The one opt-out flag: checked (the rendered default) saves true, an
60
+ # unchecked box saves an explicit false — both spellings of ON collapse.
61
+ tool_persistence: r.params["tool_persistence"] == "1",
62
+ tool_output_compression: r.params["tool_output_compression"] == "1",
63
+ subagents: list_patch(r, "subagents"),
64
+ capabilities: list_patch(r, "capabilities"),
65
+ tools_deferred: list_patch(r, "tools_deferred"),
66
+ briefing_fields: list_patch(r, "briefing_fields"),
67
+ context_providers: allowlist_patch(r, "context_providers"),
68
+ workflows_allow: allowlist_patch(r, "workflows_allow"),
69
+ approvals_required: list_patch(r, "approvals_required"),
70
+ policies: list_patch(r, "policies").map(&:to_sym),
71
+ prompt_refs: list_patch(r, "prompt_refs"),
72
+ capabilities_declared: list_patch(r, "capabilities_declared"),
73
+ skills_eager: skills_eager_patch(r)
38
74
  }
39
75
  end
40
76
 
41
- # Guardrails config from the form. The config form OWNS these fields,
42
- # so the patch reflects the whole guardrails state. String values round-trip
43
- # cleanly through the JSON store; Safety::Config normalizes on read. A blank
44
- # moderator drops the key (deterministic only).
77
+ # grounding { "mode" => "flag"|"enforce", "matcher" =>
78
+ # { "sku" => "sku:…", "name_keys" => [...] } }. Blank mode = no grounding
79
+ # (parity). The matcher keys are carried only while a mode is declared.
80
+ def grounding_patch(r)
81
+ mode = presence(r.params["grounding_mode"])
82
+ return nil unless %w[flag enforce].include?(mode)
83
+
84
+ matcher = {}
85
+ (sku = presence(r.params["grounding_matcher_sku"])) && (matcher["sku"] = sku)
86
+ keys = list(r.params["grounding_name_keys"])
87
+ matcher["name_keys"] = keys unless keys.empty?
88
+ { "mode" => mode, "matcher" => matcher }
89
+ end
90
+
91
+ # the outcome funnel — the pack's own stage vocabulary, edited
92
+ # hot. Blank stages = no funnel (parity). Stages: one per line.
93
+ def funnel_patch(r)
94
+ stages = lines(r.params["funnel_stages"])
95
+ return nil if stages.empty?
96
+
97
+ out = { "stages" => stages }
98
+ (p = presence(r.params["funnel_primary"])) && (out["primary"] = p)
99
+ (w = presence(r.params["funnel_attribution_window"])) && (out["attribution_window"] = w)
100
+ advance = json_block(r.params["funnel_advance_on"], "funnel advance_on")
101
+ out["advance_on"] = advance unless advance.nil? || advance.empty?
102
+ out
103
+ end
104
+
105
+ # the follow-up declaration. Blank arm = the feature is off
106
+ # (parity). The policy keys are all rendered; a blank one drops the key.
107
+ def followup_patch(r)
108
+ arm = presence(r.params["followup_arm"])
109
+ return nil unless arm
110
+
111
+ policy = {}
112
+ tz = presence(r.params["followup_tz"])
113
+ if tz
114
+ quiet = { "timezone" => tz }
115
+ (s = presence(r.params["followup_quiet_start"])) && (quiet["start"] = s)
116
+ (e = presence(r.params["followup_quiet_end"])) && (quiet["end"] = e)
117
+ policy["quiet_hours"] = quiet
118
+ end
119
+ (f = presence(r.params["followup_max_frequency"])) && (policy["max_frequency"] = f)
120
+ keywords = list(r.params["followup_cancel_keywords"])
121
+ policy["cancel_keywords"] = keywords unless keywords.empty?
122
+ silence = edge_int(r.params["followup_silence_after_sends"], "followup_silence_after_sends")
123
+ policy["silence_after_sends"] = silence unless silence.nil?
124
+ { "arm" => arm, "policy" => policy }
125
+ end
126
+
127
+ # distillation — enabled checkbox + the forge's knobs. Blank
128
+ # prompt/model drop the keys (the engine defaults take over).
129
+ def distill_patch(r)
130
+ enabled = r.params["distill_enabled"] == "1"
131
+ out = { "enabled" => enabled }
132
+ (p = presence(r.params["distill_prompt"])) && (out["prompt"] = p)
133
+ (m = presence(r.params["distill_model"])) && (out["model"] = m)
134
+ %w[idle_hours min_messages max_proposals].each do |key|
135
+ v = edge_int(r.params["distill_#{key}"], "distill_#{key}")
136
+ out[key] = v unless v.nil?
137
+ end
138
+ out
139
+ end
140
+
141
+ # the gated harvest. Same shape discipline as distill; the
142
+ # negative list is a JSON array of { rule, pattern, note }.
143
+ def harvest_patch(r)
144
+ enabled = r.params["harvest_enabled"] == "1"
145
+ out = { "enabled" => enabled }
146
+ (p = presence(r.params["harvest_prompt"])) && (out["prompt"] = p)
147
+ %w[idle_hours min_messages].each do |key|
148
+ v = edge_int(r.params["harvest_#{key}"], "harvest_#{key}")
149
+ out[key] = v unless v.nil?
150
+ end
151
+ negative = json_block(r.params["harvest_negative_list"], "harvest negative_list")
152
+ out["negative_list"] = negative unless negative.nil?
153
+ miner = harvest_miner_patch(r)
154
+ out["miner"] = miner unless miner.empty?
155
+ out
156
+ end
157
+
158
+ def harvest_miner_patch(r)
159
+ miner = {}
160
+ (m = presence(r.params["harvest_miner_model"])) && (miner["model"] = m)
161
+ window = {}
162
+ (w = edge_int(r.params["harvest_miner_window"], "harvest_miner_window")) && (window["last_sessions"] = w)
163
+ miner["window"] = window unless window.empty?
164
+ (m = edge_int(r.params["harvest_miner_max_proposals"], "harvest_miner_max_proposals")) && (miner["max_proposals"] = m)
165
+ budget = {}
166
+ (t = edge_int(r.params["harvest_miner_budget_tokens"], "harvest_miner_budget_tokens")) && (budget["tokens"] = t)
167
+ miner["budget"] = budget unless budget.empty?
168
+ miner
169
+ end
170
+
171
+ # Refinement: mode select + the report/propose/auto_apply knobs. Blank
172
+ # mode = report-only (the no-opt-in default — the form's "report" option
173
+ # writes it down explicitly).
174
+ def refinement_patch(r)
175
+ mode = presence(r.params["refinement_mode"])
176
+ out = { "mode" => mode } if mode
177
+ (w = edge_int(r.params["refinement_window"], "refinement_window")) && (out["window"] = { "last_sessions" => w })
178
+ (f = edge_int(r.params["refinement_max_findings"], "refinement_max_findings")) && (out["max_findings"] = f)
179
+ files = list(r.params["refinement_files"])
180
+ out["files"] = files unless files.empty?
181
+ proposers = list(r.params["refinement_proposers"])
182
+ out["proposers"] = proposers unless proposers.empty?
183
+ (t = edge_int(r.params["refinement_budget_tokens"], "refinement_budget_tokens")) && (out["budget"] = { "tokens" => t })
184
+ (e = edge_int(r.params["refinement_auto_apply_max_edits"], "refinement_auto_apply_max_edits")) && (out["auto_apply_max_edits"] = e)
185
+ out
186
+ end
187
+
188
+ # WS2: the calendar spend caps. All blank = no budget (parity).
189
+ def budget_patch(r)
190
+ daily = edge_int(r.params["budget_daily"], "budget_daily")
191
+ monthly = edge_int(r.params["budget_monthly"], "budget_monthly")
192
+ return nil if daily.nil? && monthly.nil?
193
+
194
+ out = {}
195
+ out["daily"] = daily unless daily.nil?
196
+ out["monthly"] = monthly unless monthly.nil?
197
+ (a = edge_float(r.params["budget_alert_at"], "budget_alert_at")) && (out["alert_at"] = a)
198
+ out["soft"] = r.params["budget_soft"] == "1"
199
+ out
200
+ end
201
+
202
+ # WS3: retries/backoff/fallback/breaker/timeout. All blank = the plain
203
+ # single attempt (parity).
204
+ def reliability_patch(r)
205
+ retries = edge_int(r.params["reliability_retries"], "reliability_retries")
206
+ backoff = presence(r.params["reliability_backoff"])
207
+ timeout = edge_int(r.params["reliability_timeout"], "reliability_timeout")
208
+ fallback = list(r.params["reliability_fallback"])
209
+ return nil if retries.nil? && backoff.nil? && timeout.nil? && fallback.empty?
210
+
211
+ out = {}
212
+ out["retries"] = retries unless retries.nil?
213
+ out["backoff"] = backoff if backoff
214
+ out["timeout"] = timeout unless timeout.nil?
215
+ out["fallback"] = fallback unless fallback.empty?
216
+ breaker = {}
217
+ %w[after within cooldown].each do |key|
218
+ v = edge_int(r.params["reliability_breaker_#{key}"], "reliability_breaker_#{key}")
219
+ breaker[key] = v unless v.nil?
220
+ end
221
+ out["circuit_breaker"] = breaker unless breaker.empty?
222
+ out
223
+ end
224
+
225
+ # WS6: the operator-alert webhook. Blank = no webhook (parity).
226
+ def alerts_patch(r)
227
+ url = presence(r.params["alerts_webhook"])
228
+ url ? { "webhook" => url } : nil
229
+ end
230
+
231
+ # Which internal channels may cross to the customer. Both checkboxes are
232
+ # rendered, so the patch reflects the whole state (false = held back).
233
+ def edge_stream_patch(r)
234
+ { "thinking" => r.params["edge_thinking"] == "1",
235
+ "intermediate" => r.params["edge_intermediate"] == "1" }
236
+ end
237
+
238
+ # skills_eager: blank = none (progressive disclosure, parity); "all" =
239
+ # blanket eager; otherwise a comma list of exactly these.
240
+ def skills_eager_patch(r)
241
+ raw = r.params["skills_eager"].to_s.strip
242
+ return nil if raw.empty?
243
+
244
+ raw == "all" ? true : list(raw)
245
+ end
246
+
247
+ # A JSON textarea -> parsed Hash/Array, or nil when blank. Malformed JSON
248
+ # is a ValidationError (a red flash, never a silent drop).
249
+ def json_patch(r, field)
250
+ json_block(r.params[field], field)
251
+ end
252
+
253
+ def json_block(raw, field)
254
+ text = raw.to_s.strip
255
+ return nil if text.empty?
256
+
257
+ begin
258
+ JSON.parse(text)
259
+ rescue JSON::ParserError => e
260
+ raise Insika::ValidationError, "#{field}: invalid JSON (#{e.message.lines.first.to_s.strip})"
261
+ end
262
+ end
263
+
264
+ # A comma-separated list field. Blank = [] (none) — the explicit empty
265
+ # reading; an allowlist-style field wants nil=all and should not use this.
266
+ def list_patch(r, field)
267
+ list(r.params[field])
268
+ end
269
+
270
+ # An ALLOWLIST-style field (nil = all, [] = none). Blank → nil (all), the
271
+ # open reading; a typed list closes it to exactly those names.
272
+ def allowlist_patch(r, field)
273
+ names = list(r.params[field])
274
+ names.empty? ? nil : names
275
+ end
276
+
277
+ def list(str)
278
+ str.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?).uniq
279
+ end
280
+
281
+ # One non-blank token per line.
282
+ def lines(str)
283
+ str.to_s.lines.map(&:strip).reject(&:empty?)
284
+ end
285
+
286
+ # Guardrails config from the form. The config form OWNS the fields it
287
+ # renders, so the patch reflects the whole rendered state. String values
288
+ # round-trip cleanly through the JSON store; Safety::Config normalizes on
289
+ # read. A blank moderator drops the key (deterministic only).
290
+ #
291
+ # `corpora` (the removability knob — languages/extra) has NO
292
+ # form field: it is DSL/pack data (docs/domain.md), not Studio UI. The
293
+ # form must not erase it on save — carry the existing value through
294
+ # (the shallow patch merge would otherwise wipe it, the halt_when class).
45
295
  def guardrails_patch(r)
46
296
  out = {
47
297
  "input" => r.params["guardrail_input"] == "1",
@@ -51,6 +301,8 @@ module Studio
51
301
  (mod = presence(r.params["guardrail_moderator"])) && (out["moderator"] = mod)
52
302
  responses = guardrail_responses_patch(r)
53
303
  out["responses"] = responses unless responses.empty?
304
+ existing = @agent.guardrails || {}
305
+ out["corpora"] = existing["corpora"] if existing["corpora"]
54
306
  out
55
307
  end
56
308
 
@@ -173,6 +425,11 @@ module Studio
173
425
  v = presence(r.params[f])
174
426
  patch[f] = Integer(v) if v
175
427
  end
428
+ # memory TTL (days). Blank = off (nil); a number sets the
429
+ # platform default for EVERY cell. Saving here replaces an ops-authored
430
+ # per-tenant map in the record — the view says so.
431
+ v = presence(r.params["memory_ttl_days"])
432
+ patch["memory_ttl_days"] = v.nil? ? nil : Integer(v)
176
433
  patch
177
434
  end
178
435
 
@@ -20,7 +20,12 @@ module Studio
20
20
  tasks: '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="m18 9 3 3-3 3"/>',
21
21
  approvals: '<path d="M9 12l2 2 4-4"/><path d="M12 3a9 9 0 1 0 9 9"/>',
22
22
  refinement: '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v5h-5"/><path d="m9 12 2 2 4-4"/>',
23
- evals: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>'
23
+ evals: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
24
+ parity: '<path d="M7 21h10"/><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M3 17h4l1.5-1.5L6 13 3 15.5z"/><path d="M21 17h-4l-1.5-1.5L18 13l3 2.5z"/>',
25
+ funnel: '<path d="M3 5h18l-8 9v6l-2 1v-7z"/>',
26
+ followups: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
27
+ facts: '<path d="M7 3h11a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7z"/><path d="M7 3v18"/><path d="M11 8h5"/><path d="M11 12h5"/><path d="M11 16h3"/>',
28
+ harvest: '<path d="M4 20V10"/><path d="M20 20V4"/><path d="M4 10h4v10"/><path d="M20 4h-4v16"/><path d="M4 20h16"/><path d="m8 8 4-4 4 4"/>'
24
29
  }.freeze
25
30
 
26
31
  def nav_icon(key)