insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -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),
@@ -26,6 +30,21 @@ module Studio
26
30
  limits[field.to_sym] = v
27
31
  end
28
32
  end
33
+ # Burst policy (QueuePolicy). Opt-in keys, same convention as the edge
34
+ # overrides above: blank DELETES (inherit platform / DEFAULTS — followup,
35
+ # steer_max 5); an explicit value pins it for this agent. steer_max 0 is
36
+ # a legitimate "never steer" and is stored, not deleted.
37
+ if (qm = presence(r.params["queue_mode"]))
38
+ unless Insika::QueuePolicy::MODES.map(&:to_s).include?(qm)
39
+ raise Insika::ValidationError, "queue_mode must be one of: #{Insika::QueuePolicy::MODES.join(', ')}"
40
+ end
41
+
42
+ limits[:queue_mode] = qm
43
+ else
44
+ limits.delete(:queue_mode)
45
+ end
46
+ smm = edge_int(r.params["steer_max_messages"], "steer_max_messages")
47
+ smm.nil? ? limits.delete(:steer_max_messages) : limits[:steer_max_messages] = smm
29
48
  {
30
49
  id: @agent.id,
31
50
  model: presence(r.params["model"]),
@@ -34,14 +53,276 @@ module Studio
34
53
  limits: limits,
35
54
  params: params_patch(r),
36
55
  model_policy: model_policy_patch(r),
37
- guardrails: guardrails_patch(r)
56
+ guardrails: guardrails_patch(r),
57
+ # Every remaining AgentProfile field is editable here — the pack
58
+ # import is one door, this form is the other (hot, no restart).
59
+ grounding: grounding_patch(r),
60
+ funnel: funnel_patch(r),
61
+ followup: followup_patch(r),
62
+ schedules: schedules_patch(r),
63
+ distill: distill_patch(r),
64
+ harvest: harvest_patch(r),
65
+ refinement: refinement_patch(r),
66
+ budget: budget_patch(r),
67
+ reliability: reliability_patch(r),
68
+ routes: json_patch(r, "routes"),
69
+ outputs: json_patch(r, "outputs"),
70
+ stt_prompt: presence(r.params["stt_prompt"]),
71
+ metadata: json_patch(r, "metadata") || {},
72
+ alerts: alerts_patch(r),
73
+ edge_stream: edge_stream_patch(r),
74
+ stuck_signal: r.params["stuck_signal"] == "1",
75
+ prompt_caching: r.params["prompt_caching"] == "1",
76
+ # The one opt-out flag: checked (the rendered default) saves true, an
77
+ # unchecked box saves an explicit false — both spellings of ON collapse.
78
+ tool_persistence: r.params["tool_persistence"] == "1",
79
+ tool_output_compression: r.params["tool_output_compression"] == "1",
80
+ subagents: list_patch(r, "subagents"),
81
+ capabilities: list_patch(r, "capabilities"),
82
+ tools_deferred: list_patch(r, "tools_deferred"),
83
+ briefing_fields: list_patch(r, "briefing_fields"),
84
+ context_providers: allowlist_patch(r, "context_providers"),
85
+ workflows_allow: allowlist_patch(r, "workflows_allow"),
86
+ approvals_required: list_patch(r, "approvals_required"),
87
+ policies: list_patch(r, "policies").map(&:to_sym),
88
+ prompt_refs: list_patch(r, "prompt_refs"),
89
+ capabilities_declared: list_patch(r, "capabilities_declared"),
90
+ skills_eager: skills_eager_patch(r)
38
91
  }
39
92
  end
40
93
 
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).
94
+ # grounding { "mode" => "flag"|"enforce", "matcher" =>
95
+ # { "sku" => "sku:…", "name_keys" => [...] } }. Blank mode = no grounding
96
+ # (parity). The matcher keys are carried only while a mode is declared.
97
+ def grounding_patch(r)
98
+ mode = presence(r.params["grounding_mode"])
99
+ return nil unless %w[flag enforce].include?(mode)
100
+
101
+ matcher = {}
102
+ (sku = presence(r.params["grounding_matcher_sku"])) && (matcher["sku"] = sku)
103
+ keys = list(r.params["grounding_name_keys"])
104
+ matcher["name_keys"] = keys unless keys.empty?
105
+ { "mode" => mode, "matcher" => matcher }
106
+ end
107
+
108
+ # the outcome funnel — the pack's own stage vocabulary, edited
109
+ # hot. Blank stages = no funnel (parity). Stages: one per line.
110
+ def funnel_patch(r)
111
+ stages = lines(r.params["funnel_stages"])
112
+ return nil if stages.empty?
113
+
114
+ out = { "stages" => stages }
115
+ (p = presence(r.params["funnel_primary"])) && (out["primary"] = p)
116
+ (w = presence(r.params["funnel_attribution_window"])) && (out["attribution_window"] = w)
117
+ advance = json_block(r.params["funnel_advance_on"], "funnel advance_on")
118
+ out["advance_on"] = advance unless advance.nil? || advance.empty?
119
+ out
120
+ end
121
+
122
+ # the follow-up declaration. Blank arm = the feature is off
123
+ # (parity). The policy keys are all rendered; a blank one drops the key.
124
+ def followup_patch(r)
125
+ arm = presence(r.params["followup_arm"])
126
+ return nil unless arm
127
+
128
+ policy = {}
129
+ tz = presence(r.params["followup_tz"])
130
+ if tz
131
+ quiet = { "timezone" => tz }
132
+ (s = presence(r.params["followup_quiet_start"])) && (quiet["start"] = s)
133
+ (e = presence(r.params["followup_quiet_end"])) && (quiet["end"] = e)
134
+ policy["quiet_hours"] = quiet
135
+ end
136
+ (f = presence(r.params["followup_max_frequency"])) && (policy["max_frequency"] = f)
137
+ keywords = list(r.params["followup_cancel_keywords"])
138
+ policy["cancel_keywords"] = keywords unless keywords.empty?
139
+ silence = edge_int(r.params["followup_silence_after_sends"], "followup_silence_after_sends")
140
+ policy["silence_after_sends"] = silence unless silence.nil?
141
+ { "arm" => arm, "policy" => policy }
142
+ end
143
+
144
+ # recurring schedules — a JSON array of declarations
145
+ # (name / trigger / tz / message / session mode / overrides / enabled).
146
+ # Same honest shape as routes/metadata: the list is unbounded, and a
147
+ # JSON textarea is the CSP-safe editor. Blank = no schedules (parity).
148
+ def schedules_patch(r)
149
+ parsed = json_block(r.params["schedules"], "schedules")
150
+ return nil if parsed.nil?
151
+
152
+ parsed = parsed.is_a?(Hash) ? [parsed] : parsed
153
+ raise Insika::ValidationError, "schedules must be an array of declarations" unless parsed.is_a?(Array)
154
+
155
+ parsed
156
+ end
157
+
158
+ # distillation — enabled checkbox + the forge's knobs. Blank
159
+ # prompt/model drop the keys (the engine defaults take over).
160
+ def distill_patch(r)
161
+ enabled = r.params["distill_enabled"] == "1"
162
+ out = { "enabled" => enabled }
163
+ (p = presence(r.params["distill_prompt"])) && (out["prompt"] = p)
164
+ (m = presence(r.params["distill_model"])) && (out["model"] = m)
165
+ %w[idle_hours min_messages max_proposals].each do |key|
166
+ v = edge_int(r.params["distill_#{key}"], "distill_#{key}")
167
+ out[key] = v unless v.nil?
168
+ end
169
+ out
170
+ end
171
+
172
+ # the gated harvest. Same shape discipline as distill; the
173
+ # negative list is a JSON array of { rule, pattern, note }.
174
+ def harvest_patch(r)
175
+ enabled = r.params["harvest_enabled"] == "1"
176
+ out = { "enabled" => enabled }
177
+ (p = presence(r.params["harvest_prompt"])) && (out["prompt"] = p)
178
+ %w[idle_hours min_messages].each do |key|
179
+ v = edge_int(r.params["harvest_#{key}"], "harvest_#{key}")
180
+ out[key] = v unless v.nil?
181
+ end
182
+ negative = json_block(r.params["harvest_negative_list"], "harvest negative_list")
183
+ out["negative_list"] = negative unless negative.nil?
184
+ miner = harvest_miner_patch(r)
185
+ out["miner"] = miner unless miner.empty?
186
+ out
187
+ end
188
+
189
+ def harvest_miner_patch(r)
190
+ miner = {}
191
+ (m = presence(r.params["harvest_miner_model"])) && (miner["model"] = m)
192
+ window = {}
193
+ (w = edge_int(r.params["harvest_miner_window"], "harvest_miner_window")) && (window["last_sessions"] = w)
194
+ miner["window"] = window unless window.empty?
195
+ (m = edge_int(r.params["harvest_miner_max_proposals"], "harvest_miner_max_proposals")) && (miner["max_proposals"] = m)
196
+ budget = {}
197
+ (t = edge_int(r.params["harvest_miner_budget_tokens"], "harvest_miner_budget_tokens")) && (budget["tokens"] = t)
198
+ miner["budget"] = budget unless budget.empty?
199
+ miner
200
+ end
201
+
202
+ # Refinement: mode select + the report/propose/auto_apply knobs. Blank
203
+ # mode = report-only (the no-opt-in default — the form's "report" option
204
+ # writes it down explicitly).
205
+ def refinement_patch(r)
206
+ mode = presence(r.params["refinement_mode"])
207
+ out = { "mode" => mode } if mode
208
+ (w = edge_int(r.params["refinement_window"], "refinement_window")) && (out["window"] = { "last_sessions" => w })
209
+ (f = edge_int(r.params["refinement_max_findings"], "refinement_max_findings")) && (out["max_findings"] = f)
210
+ files = list(r.params["refinement_files"])
211
+ out["files"] = files unless files.empty?
212
+ proposers = list(r.params["refinement_proposers"])
213
+ out["proposers"] = proposers unless proposers.empty?
214
+ (t = edge_int(r.params["refinement_budget_tokens"], "refinement_budget_tokens")) && (out["budget"] = { "tokens" => t })
215
+ (e = edge_int(r.params["refinement_auto_apply_max_edits"], "refinement_auto_apply_max_edits")) && (out["auto_apply_max_edits"] = e)
216
+ out
217
+ end
218
+
219
+ # WS2: the calendar spend caps. All blank = no budget (parity).
220
+ def budget_patch(r)
221
+ daily = edge_int(r.params["budget_daily"], "budget_daily")
222
+ monthly = edge_int(r.params["budget_monthly"], "budget_monthly")
223
+ return nil if daily.nil? && monthly.nil?
224
+
225
+ out = {}
226
+ out["daily"] = daily unless daily.nil?
227
+ out["monthly"] = monthly unless monthly.nil?
228
+ (a = edge_float(r.params["budget_alert_at"], "budget_alert_at")) && (out["alert_at"] = a)
229
+ out["soft"] = r.params["budget_soft"] == "1"
230
+ out
231
+ end
232
+
233
+ # WS3: retries/backoff/fallback/breaker/timeout. All blank = the plain
234
+ # single attempt (parity).
235
+ def reliability_patch(r)
236
+ retries = edge_int(r.params["reliability_retries"], "reliability_retries")
237
+ backoff = presence(r.params["reliability_backoff"])
238
+ timeout = edge_int(r.params["reliability_timeout"], "reliability_timeout")
239
+ fallback = list(r.params["reliability_fallback"])
240
+ return nil if retries.nil? && backoff.nil? && timeout.nil? && fallback.empty?
241
+
242
+ out = {}
243
+ out["retries"] = retries unless retries.nil?
244
+ out["backoff"] = backoff if backoff
245
+ out["timeout"] = timeout unless timeout.nil?
246
+ out["fallback"] = fallback unless fallback.empty?
247
+ breaker = {}
248
+ %w[after within cooldown].each do |key|
249
+ v = edge_int(r.params["reliability_breaker_#{key}"], "reliability_breaker_#{key}")
250
+ breaker[key] = v unless v.nil?
251
+ end
252
+ out["circuit_breaker"] = breaker unless breaker.empty?
253
+ out
254
+ end
255
+
256
+ # WS6: the operator-alert webhook. Blank = no webhook (parity).
257
+ def alerts_patch(r)
258
+ url = presence(r.params["alerts_webhook"])
259
+ url ? { "webhook" => url } : nil
260
+ end
261
+
262
+ # Which internal channels may cross to the customer. Both checkboxes are
263
+ # rendered, so the patch reflects the whole state (false = held back).
264
+ def edge_stream_patch(r)
265
+ { "thinking" => r.params["edge_thinking"] == "1",
266
+ "intermediate" => r.params["edge_intermediate"] == "1" }
267
+ end
268
+
269
+ # skills_eager: blank = none (progressive disclosure, parity); "all" =
270
+ # blanket eager; otherwise a comma list of exactly these.
271
+ def skills_eager_patch(r)
272
+ raw = r.params["skills_eager"].to_s.strip
273
+ return nil if raw.empty?
274
+
275
+ raw == "all" ? true : list(raw)
276
+ end
277
+
278
+ # A JSON textarea -> parsed Hash/Array, or nil when blank. Malformed JSON
279
+ # is a ValidationError (a red flash, never a silent drop).
280
+ def json_patch(r, field)
281
+ json_block(r.params[field], field)
282
+ end
283
+
284
+ def json_block(raw, field)
285
+ text = raw.to_s.strip
286
+ return nil if text.empty?
287
+
288
+ begin
289
+ JSON.parse(text)
290
+ rescue JSON::ParserError => e
291
+ raise Insika::ValidationError, "#{field}: invalid JSON (#{e.message.lines.first.to_s.strip})"
292
+ end
293
+ end
294
+
295
+ # A comma-separated list field. Blank = [] (none) — the explicit empty
296
+ # reading; an allowlist-style field wants nil=all and should not use this.
297
+ def list_patch(r, field)
298
+ list(r.params[field])
299
+ end
300
+
301
+ # An ALLOWLIST-style field (nil = all, [] = none). Blank → nil (all), the
302
+ # open reading; a typed list closes it to exactly those names.
303
+ def allowlist_patch(r, field)
304
+ names = list(r.params[field])
305
+ names.empty? ? nil : names
306
+ end
307
+
308
+ def list(str)
309
+ str.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?).uniq
310
+ end
311
+
312
+ # One non-blank token per line.
313
+ def lines(str)
314
+ str.to_s.lines.map(&:strip).reject(&:empty?)
315
+ end
316
+
317
+ # Guardrails config from the form. The config form OWNS the fields it
318
+ # renders, so the patch reflects the whole rendered state. String values
319
+ # round-trip cleanly through the JSON store; Safety::Config normalizes on
320
+ # read. A blank moderator drops the key (deterministic only).
321
+ #
322
+ # `corpora` (the removability knob — languages/extra) has NO
323
+ # form field: it is DSL/pack data (docs/domain.md), not Studio UI. The
324
+ # form must not erase it on save — carry the existing value through
325
+ # (the shallow patch merge would otherwise wipe it, the halt_when class).
45
326
  def guardrails_patch(r)
46
327
  out = {
47
328
  "input" => r.params["guardrail_input"] == "1",
@@ -51,6 +332,8 @@ module Studio
51
332
  (mod = presence(r.params["guardrail_moderator"])) && (out["moderator"] = mod)
52
333
  responses = guardrail_responses_patch(r)
53
334
  out["responses"] = responses unless responses.empty?
335
+ existing = @agent.guardrails || {}
336
+ out["corpora"] = existing["corpora"] if existing["corpora"]
54
337
  out
55
338
  end
56
339
 
@@ -173,6 +456,11 @@ module Studio
173
456
  v = presence(r.params[f])
174
457
  patch[f] = Integer(v) if v
175
458
  end
459
+ # memory TTL (days). Blank = off (nil); a number sets the
460
+ # platform default for EVERY cell. Saving here replaces an ops-authored
461
+ # per-tenant map in the record — the view says so.
462
+ v = presence(r.params["memory_ttl_days"])
463
+ patch["memory_ttl_days"] = v.nil? ? nil : Integer(v)
176
464
  patch
177
465
  end
178
466
 
@@ -302,19 +590,24 @@ module Studio
302
590
  }
303
591
  end
304
592
 
305
- # MCP instance from the form. `env` comes as "KEY=value" lines (CSP
306
- # forbids inline JS for add/remove line; a textarea is the simple, honest
307
- # path). Masked values come back as a sentinel — keeping them preserves
308
- # the secret; changing replaces; deleting the line clears it.
593
+ # MCP instance from the form (transport-aware, but the
594
+ # form always submits every field regardless of which the JS shows;
595
+ # McpStore stores them all either way). `env`/`headers` come as
596
+ # "KEY=value" lines, `args` as one argv token per line (CSP forbids
597
+ # inline JS for add/remove line; a textarea is the simple, honest path).
598
+ # Masked credential values come back as a sentinel — keeping them
599
+ # preserves the secret; changing replaces; deleting the line clears it.
309
600
  def mcp_patch(r)
310
601
  {
311
602
  name: presence(r.params["name"]),
312
603
  transport: presence(r.params["transport"]) || "stdio",
313
604
  command: r.params["command"].to_s,
605
+ args: split_lines(r.params["args"]),
314
606
  url: r.params["url"].to_s,
315
607
  description: r.params["description"].to_s,
316
608
  enabled: r.params["enabled"] == "1",
317
- env: parse_kv_lines(r.params["env"])
609
+ env: parse_kv_lines(r.params["env"]),
610
+ headers: parse_kv_lines(r.params["headers"])
318
611
  }
319
612
  end
320
613
 
@@ -331,5 +624,10 @@ module Studio
331
624
  [k, v.to_s.strip]
332
625
  end.to_h
333
626
  end
627
+
628
+ # One argv token per line (stdio's `args`). Ignores blank lines/comments.
629
+ def split_lines(text)
630
+ text.to_s.each_line.map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
631
+ end
334
632
  end
335
633
  end
@@ -20,12 +20,30 @@ 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"/>',
29
+ knowledge: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z"/>',
30
+ artifacts: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="m9.5 13 2 2 3.5-3.5"/><path d="M12 15v-4"/>'
24
31
  }.freeze
25
32
 
26
33
  def nav_icon(key)
27
34
  %(<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ) +
28
35
  %(stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">#{NAV_ICONS[key]}</svg>)
29
36
  end
37
+
38
+ # Transcript event-chip icons: a tool call, a result, a
39
+ # skill, an artifact, a guardrail — each reuses a nav icon (same inline-SVG
40
+ # grammar, CSP-safe). Sized by the chip CSS, colored per kind.
41
+ CHIP_ICONS = { call: :tools, result: :approvals, skill: :skills,
42
+ artifact: :artifacts, guardrail: :system }.freeze
43
+
44
+ def chip_icon(kind)
45
+ key = CHIP_ICONS[kind]
46
+ key ? %(<span class="chip-ic">#{nav_icon(key)}</span>) : ""
47
+ end
30
48
  end
31
49
  end
@@ -0,0 +1,25 @@
1
+ <!-- ── Cache ─────────────────────────────────────────────────── -->
2
+ <section class="section" id="cache" data-tabs-target="panel">
3
+ <div class="section-head">
4
+ <h2>Cache</h2>
5
+ <p class="muted">prefix cache-hit per turn (provider-reported) — the invalidation reason names the first block whose bytes changed vs the previous turn.</p>
6
+ </div>
7
+ <div class="card">
8
+ <% if @cache_series.empty? %>
9
+ <p class="muted">No turns recorded.</p>
10
+ <% else %>
11
+ <ul class="kv">
12
+ <% @cache_series.last(20).reverse.each do |c| %>
13
+ <li>
14
+ <span class="k mono"><%= c["at"] %></span>
15
+ <span class="v">
16
+ <%== c["hit_pct"].nil? ? "—" : "#{c['hit_pct']}% cached" %>
17
+ · <%= c["cached_tokens"] %>/<%= c["prompt_tokens"] %> tokens
18
+ <% if c["invalidation_reason"] %><span class="lat">broke: <%= c["invalidation_reason"] %></span><% end %>
19
+ </span>
20
+ </li>
21
+ <% end %>
22
+ </ul>
23
+ <% end %>
24
+ </div>
25
+ </section>