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,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>
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # Form → command-payload parsing for the Studio App (§11 B6). A mixin, NOT a
4
+ # Form → command-payload parsing for the Studio App. A mixin, NOT a
5
5
  # standalone object: `include`d into App so every method keeps `self` == the
6
6
  # App instance and reaches its request helpers (`presence`, `split_list`) and
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),
@@ -15,7 +19,7 @@ module Studio
15
19
  v = presence(r.params[field])
16
20
  limits[field.to_sym] = Integer(v) if v
17
21
  end
18
- # Per-agent edge overrides (item 33). Unlike the timeouts (always present via
22
+ # Per-agent edge overrides. Unlike the timeouts (always present via
19
23
  # DEFAULT_LIMITS), these are OPT-IN keys: blank DELETES the override (inherit
20
24
  # the platform edge settings); 0 explicitly disables for this agent.
21
25
  %w[chat_rate_limit agent_token_ceiling].each do |field|
@@ -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 (RFC-0009). 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,10 +301,12 @@ 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
 
57
- # Per-category safe-reply overrides (RFC-0009 §7, config over convention). Only
309
+ # Per-category safe-reply overrides (config over convention). Only
58
310
  # the non-blank fields are persisted; Safety::Config normalizes on read.
59
311
  def guardrail_responses_patch(r)
60
312
  %w[default injection sexual abuse escalate].each_with_object({}) do |cat, acc|
@@ -62,7 +314,7 @@ module Studio
62
314
  end
63
315
  end
64
316
 
65
- # Per-agent generation params (v2, §10). The config form OWNS these fields, so
317
+ # Per-agent generation params. The config form OWNS these fields, so
66
318
  # the patch reflects the whole form state: a blank field drops the key (and an
67
319
  # all-blank form clears params to {}). temperature/max_tokens MUST be Numeric —
68
320
  # ModelSelection#apply_params guards on `numeric?` and silently skips a String —
@@ -79,7 +331,7 @@ module Studio
79
331
  out
80
332
  end
81
333
 
82
- # Per-agent model fence (v2, §10): { "allow" => [refs] } where a ref is
334
+ # Per-agent model fence: { "allow" => [refs] } where a ref is
83
335
  # "provider/model", "provider/*" or "model". Blank textarea -> nil (NO fence,
84
336
  # all models — parity). Enforced on the RESOLVED model by the ModelResolver,
85
337
  # so a per-chat pin can never escape it.
@@ -101,7 +353,7 @@ module Studio
101
353
 
102
354
  # Fields a tool CAN carry that this form does not render. The store REPLACES the
103
355
  # record on write, so anything missing here is erased — a save that only fixed a
104
- # typo in the description would silently drop them (the class of bug §12 already
356
+ # typo in the description would silently drop them (the class of bug already
105
357
  # paid for once). `stored` carries them through untouched.
106
358
  UNEDITED_TOOL_FIELDS = %w[group tags halt_when].freeze
107
359
 
@@ -166,22 +418,22 @@ module Studio
166
418
 
167
419
  def settings_patch(r)
168
420
  patch = {
169
- "streaming" => r.params["streaming"] == "1",
170
- "compaction" => {
171
- "enabled" => r.params["compaction_enabled"] == "1"
172
- }
421
+ "streaming" => r.params["streaming"] == "1"
173
422
  }
174
423
  { "request_timeout" => "request_timeout", "max_retries" => "max_retries",
175
424
  "turn_timeout" => "turn_timeout", "tool_timeout" => "tool_timeout" }.each_key do |f|
176
425
  v = presence(r.params[f])
177
426
  patch[f] = Integer(v) if v
178
427
  end
179
- kl = presence(r.params["keep_last"])
180
- patch["compaction"]["keep_last"] = Integer(kl) if kl
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)
181
433
  patch
182
434
  end
183
435
 
184
- # Edge limits (item 33 / §12 G7) — the platform rate-limit/cost layer, saved
436
+ # Edge limits — the platform rate-limit/cost layer, saved
185
437
  # from its OWN form (a sub-resource, like models). The limit fields write nil
186
438
  # when blank (off — the EdgeLimiter reads nil/0 as off); the windows fall back
187
439
  # to the built-in defaults when cleared (the limiter guards non-positive).
@@ -209,7 +461,7 @@ module Studio
209
461
  { case: parsed }
210
462
  end
211
463
 
212
- # Evals graders (RFC-0013 §3.9). `judges` is one `provider/model` per LINE — a
464
+ # Evals graders. `judges` is one `provider/model` per LINE — a
213
465
  # textarea, because the SIZE of the panel is the point and a fixed pair of fields
214
466
  # would cap it at two. A bare `model` (no slash) is valid: the provider is then
215
467
  # inferred by RubyLLM, same as everywhere else.
@@ -258,7 +510,7 @@ module Studio
258
510
  raise Insika::ValidationError, "#{field} must be an integer (got #{raw.inspect})"
259
511
  end
260
512
 
261
- # LLM config v2 (§10) — the platform model layer, saved from its OWN form (a
513
+ # LLM config v2 — the platform model layer, saved from its OWN form (a
262
514
  # sub-resource, like providers) so the general-settings save never touches it and
263
515
  # vice-versa. The scalar refs are always present (blank -> nil, which the
264
516
  # deep-merge writes and the ModelResolver reads as "no platform default");
@@ -270,12 +522,12 @@ module Studio
270
522
  "default_provider" => presence(r.params["default_provider"]),
271
523
  "utility_model" => presence(r.params["utility_model"]),
272
524
  "fallback_models" => split_list(r.params["fallback_models"]),
273
- # GLOBAL reasoning default (§10, 4-layer). Blank -> nil = provider default.
525
+ # GLOBAL reasoning default (4-layer). Blank -> nil = provider default.
274
526
  "thinking" => presence(r.params["thinking"])
275
527
  }
276
528
  end
277
529
 
278
- # Per-model reasoning defaults (§10, the per-model layer). One line per model:
530
+ # Per-model reasoning defaults (the per-model layer). One line per model:
279
531
  # <provider/model | model> | <off|on|low|medium|high>
280
532
  # CSP forbids JS for dynamic rows, so a textarea of lines is the honest path
281
533
  # (same idiom as the MCP env / tool params). A blank effort -> {} for that ref
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # Nav SVG icons for the Studio App (§11 B6). A mixin included into App: the
4
+ # Nav SVG icons for the Studio App. A mixin included into App: the
5
5
  # `nav_icon` view helper and its icon table, moved verbatim out of app.rb.
6
6
  # Inline SVG is CSP-safe (an HTML element, not an external resource).
7
7
  module NavIcons
@@ -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)
@@ -1,4 +1,4 @@
1
- <%# Shared transcript bubble/tool-card (§11 A1/A2) — one visual language for the
1
+ <%# Shared transcript bubble/tool-card — one visual language for the
2
2
  playground history, the optimistic echo and the read-only session viewer.
3
3
  Locals: m = stored message hash {"role","content","at"}; prev = the message before
4
4
  it in the thread, or nil (used only to spot a steered message). `content` may be a
@@ -22,7 +22,7 @@
22
22
  when it is NOT the natural producer for the role — otherwise every bubble
23
23
  would carry a label that says nothing. %>
24
24
  <% origin = Insika::MessageOrigin.origin_of(m) unless Insika::MessageOrigin.customer?(m) || Insika::MessageOrigin.agent?(m) %>
25
- <%# STEERED (RFC-0015): a customer message sitting right after a tool result arrived
25
+ <%# STEERED: a customer message sitting right after a tool result arrived
26
26
  while the turn was running and was appended at a tool-batch boundary. Derived
27
27
  from position, not from a field — a steered message declares no origin, and it
28
28
  is right not to: a person typed it. Nothing else in the engine ever puts a user