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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # the per-agent schedule rows of the recurring feature — one
8
+ # row per declared schedule, keyed `tenant:agent:id`, holding BOTH the
9
+ # declaration (a copy of the profile's `schedules` entry — the engine reads
10
+ # the store, never the profile, on the fire path) and the runtime state
11
+ # (`next_fire_at`, `last_run_at`, `last_task_id`, `last_skip`).
12
+ #
13
+ # The row is a DERIVED record: the profile declaration is the config (the
14
+ # DSL, the API, the Studio all edit the profile); the ScheduleEngine
15
+ # reconciles rows with declarations at each pass. The engine OWNS the
16
+ # runtime writes — `transition_fire`/`mark_skip` are called only inside the
17
+ # engine's transaction (the D5 discipline), never by a consumer. A row that
18
+ # is no longer declared is deleted (no zombie schedules); a row whose
19
+ # declaration is malformed is deleted too — a broken schedule must not keep
20
+ # firing with stale text (the doctor names it).
21
+ #
22
+ # Multi-worker at-most-once rides Store#transaction: the pass re-reads the
23
+ # row inside the transaction, so two workers serialize on the backend lock
24
+ # and only one advances `next_fire_at`.
25
+ class ScheduleStore
26
+ SCOPE = "schedules"
27
+
28
+ Record = Data.define(:id, :tenant, :agent, :every, :cron, :tz, :message,
29
+ :session_mode, :session_id, :overrides, :enabled,
30
+ :next_fire_at, :last_run_at, :last_task_id, :last_skip,
31
+ :created_at, :updated_at)
32
+
33
+ def initialize(store:)
34
+ @store = store
35
+ end
36
+
37
+ # Reconcile: upsert the declaration's row, or delete it when the
38
+ # declaration is gone/malformed. Called by the engine inside its pass
39
+ # transaction. A NEW or CHANGED declaration recomputes `next_fire_at`
40
+ # (the trigger lattice starts fresh); an unchanged row keeps its.
41
+ def sync_declared(tenant:, agent:, schedules: nil, now: Time.now.utc)
42
+ kept = []
43
+ Array(schedules).each do |declaration|
44
+ schedule = Insika::Schedule.parse(declaration)
45
+ next if schedule.nil? # malformed — dropped, the doctor names it
46
+
47
+ keep = upsert(tenant: tenant, agent: agent, schedule: schedule, now: now)
48
+ kept << schedule.id if keep
49
+ end
50
+
51
+ # drop rows the agent no longer declares
52
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
53
+ @store.list(SCOPE).each do |k|
54
+ next unless k.start_with?(prefix)
55
+ next if kept.include?(k.split(":").last)
56
+
57
+ @store.delete(SCOPE, k)
58
+ end
59
+ kept
60
+ end
61
+
62
+ # -> Record | nil
63
+ def find(tenant:, agent:, id:)
64
+ record = @store.get(SCOPE, key(tenant, agent, id))
65
+ record && to_record(record)
66
+ end
67
+
68
+ # -> [Record] — one agent's rows (the Studio's read), lexicographic.
69
+ def for_agent(tenant:, agent:)
70
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
71
+ @store.list(SCOPE).filter_map do |k|
72
+ next unless k.start_with?(prefix)
73
+
74
+ to_record(@store.get(SCOPE, k))
75
+ end
76
+ end
77
+
78
+ # -> [Record] — every row (the Studio grid, the engine pass).
79
+ def all
80
+ @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
81
+ end
82
+
83
+ # -> [Record] — enabled AND due, oldest first (determinism). A row whose
84
+ # next_fire_at is nil (a cron that can never fire) is never due.
85
+ def due(now: Time.now.utc)
86
+ cutoff = now.iso8601
87
+ all.select { |r| r.enabled && !r.next_fire_at.to_s.empty? && r.next_fire_at.to_s <= cutoff }
88
+ .sort_by { |r| [r.next_fire_at.to_s, r.id] }
89
+ end
90
+
91
+ # The engine's atomic fire claim — the LAST step of the fire's
92
+ # transaction: advance the lattice, stamp the run, clear the skip.
93
+ # Callers pass `id` and the engine's OWN transaction encloses this.
94
+ def transition_fire(id:, tenant:, agent:, task_id:, next_fire_at:, now: Time.now.utc)
95
+ mutate(tenant, agent, id, now) do |record|
96
+ record["last_run_at"] = now.utc.iso8601
97
+ record["last_task_id"] = task_id.to_s
98
+ record["next_fire_at"] = next_fire_at.iso8601
99
+ record["last_skip"] = nil
100
+ end
101
+ end
102
+
103
+ # The visible skip — recorded, never silent: `next_fire_at` advances
104
+ # (the window is skipped, not queued) and `last_skip` names why.
105
+ def mark_skip(id:, tenant:, agent:, reason:, next_fire_at:, now: Time.now.utc)
106
+ mutate(tenant, agent, id, now) do |record|
107
+ record["last_skip"] = { "at" => now.utc.iso8601, "reason" => reason.to_s }
108
+ record["next_fire_at"] = next_fire_at.iso8601
109
+ end
110
+ end
111
+
112
+ def delete(tenant:, agent:, id:)
113
+ @store.delete(SCOPE, key(tenant, agent, id))
114
+ end
115
+
116
+ # -> count removed. The LGPD sweep (a tenant's schedules die with it).
117
+ def purge(tenant:)
118
+ prefix = "#{tenant_id(tenant)}:"
119
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
120
+ keys.each { |k| @store.delete(SCOPE, k) }
121
+ keys.size
122
+ end
123
+
124
+ private
125
+
126
+ # Create or update ONE row from the declaration, recomputing next_fire_at
127
+ # on create and on any trigger/definition change. -> the schedule id.
128
+ def upsert(tenant:, agent:, schedule:, now:)
129
+ k = key(tenant, agent, schedule.id)
130
+ record = @store.get(SCOPE, k)
131
+ return schedule.id if record && !changed?(record, schedule)
132
+
133
+ next_fire_at = initial_next_fire(schedule, now)
134
+ row = {
135
+ "id" => schedule.id, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
136
+ "every" => schedule.every, "cron" => schedule.cron, "tz" => schedule.tz,
137
+ "message" => schedule.message, "session_mode" => schedule.session_mode,
138
+ "session_id" => schedule.session_id, "overrides" => schedule.overrides,
139
+ "enabled" => schedule.enabled,
140
+ "next_fire_at" => next_fire_at&.iso8601,
141
+ "last_run_at" => record&.dig("last_run_at"),
142
+ "last_task_id" => record&.dig("last_task_id"),
143
+ "last_skip" => record&.dig("last_skip"),
144
+ "created_at" => record ? record["created_at"] : now.iso8601,
145
+ "updated_at" => now.iso8601
146
+ }
147
+ @store.set(SCOPE, k, row)
148
+ schedule.id
149
+ end
150
+
151
+ # Does the row's DECLARATION differ from the schedule? (runtime state is
152
+ # never part of the comparison.)
153
+ def changed?(record, schedule)
154
+ record["cron"] != schedule.cron || record["every"] != schedule.every ||
155
+ record["tz"] != schedule.tz || record["message"] != schedule.message ||
156
+ record["session_mode"] != schedule.session_mode ||
157
+ record["session_id"] != schedule.session_id ||
158
+ record["overrides"] != schedule.overrides ||
159
+ record["enabled"] != schedule.enabled
160
+ end
161
+
162
+ # The first fire after `now` for a schedule that has none yet: for `every`,
163
+ # one interval out; for cron, the next expression match. nil (a cron that
164
+ # can never fire) makes the row never due — `due` ignores a nil.
165
+ def initial_next_fire(schedule, now)
166
+ if schedule.every
167
+ now + schedule.every
168
+ else
169
+ Insika::Cron.new(schedule.cron).next_after(now, tz: schedule.tz)
170
+ end
171
+ end
172
+
173
+ def mutate(tenant, agent, id, now)
174
+ @store.transaction do
175
+ record = @store.get(SCOPE, key(tenant, agent, id))
176
+ raise Insika::NotFoundError, "schedule not found: #{tenant}:#{agent}:#{id}" if record.nil?
177
+
178
+ yield record
179
+ record["updated_at"] = now.utc.iso8601
180
+ @store.set(SCOPE, key(tenant, agent, id), record)
181
+ to_record(record)
182
+ end
183
+ end
184
+
185
+ def key(tenant, agent, id)
186
+ "#{tenant_id(tenant)}:#{agent.to_s}:#{id.to_s}"
187
+ end
188
+
189
+ def tenant_id(tenant)
190
+ t = tenant.to_s
191
+ t.empty? ? "platform" : t
192
+ end
193
+
194
+ def to_record(rec)
195
+ return nil if rec.nil?
196
+
197
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
198
+ every: rec["every"], cron: rec["cron"], tz: rec["tz"],
199
+ message: rec["message"], session_mode: rec["session_mode"],
200
+ session_id: rec["session_id"], overrides: rec["overrides"],
201
+ enabled: rec["enabled"] != false,
202
+ next_fire_at: rec["next_fire_at"],
203
+ last_run_at: rec["last_run_at"],
204
+ last_task_id: rec["last_task_id"], last_skip: rec["last_skip"],
205
+ created_at: rec["created_at"], updated_at: rec["updated_at"])
206
+ end
207
+ end
208
+ end
@@ -131,6 +131,41 @@ module Insika
131
131
  end
132
132
  end
133
133
 
134
+ # Walks a dotted path on a plain object: nil when a segment
135
+ # is absent or the intermediate is not a Hash. Shared by the evidence output
136
+ # check and the Processor's extraction.
137
+ def dig(obj, path)
138
+ path.to_s.split(".").reduce(obj) do |cur, seg|
139
+ return nil unless cur.is_a?(Hash) && cur.key?(seg)
140
+
141
+ cur[seg]
142
+ end
143
+ end
144
+
145
+ # The evidence RESULT contract: {items: [{id, line}]} .
146
+ # -> nil | String. Same idiom as `violation`: nil = fine, one message = what
147
+ # is wrong. A malformed evidence result is a correctable TOOL answer — the
148
+ # envelope returns it to the model as `{error:}`, exactly like a malformed
149
+ # call is today. A raw body that is NOT an object (a bare JSON array from a
150
+ # search, a string, nil) is a violation — never a silent `{items: []}` that
151
+ # the model reads as "no products".
152
+ def violation_output(spec, raw)
153
+ return nil if spec.nil?
154
+ return "evidence: result must be an object" unless raw.is_a?(Hash)
155
+
156
+ items = dig(raw, spec.items_path)
157
+ return "evidence: items is missing" if items.nil?
158
+ return "evidence: items must be a list" unless items.is_a?(Array)
159
+
160
+ items.each_with_index do |item, i|
161
+ ok = item.is_a?(Hash) &&
162
+ Coercion.present?(item["id"] || item[:id]) &&
163
+ (item["line"] || item[:line]).is_a?(String)
164
+ return "evidence: items[#{i}] must be {id, line}" unless ok
165
+ end
166
+ nil
167
+ end
168
+
134
169
  # Name the shape the way a model reads it, not the way Ruby does.
135
170
  def kind(value)
136
171
  case value
@@ -55,7 +55,8 @@ module Insika
55
55
  def initialize(command_bus:, event_stream:, session_store:, task_store:,
56
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
58
- channels: nil, logger: nil, token_store: nil)
58
+ channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
59
60
  @command_bus = command_bus
60
61
  @event_stream = event_stream
61
62
  @session_store = session_store
@@ -91,10 +92,29 @@ module Insika
91
92
  # injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
92
93
  # The channel does the translating; this class keeps doing only transport.
93
94
  @channels = channels
95
+ # WS7: business outcomes per conversation (POST /v1/outcomes — a Control
96
+ # command on the bus — and the tenant-scoped GET /v1/outcomes read).
97
+ # nil = the routes 404 (parity).
98
+ @outcome_store = outcome_store
94
99
  # where a 500's error_ref goes to be FOUND. nil = silent (parity for
95
100
  # embedders); the serving wirings pass $stdout. Class+message+backtrace
96
101
  # only — the ref never travels with request payloads (secrets stay out).
97
102
  @logger = logger
103
+ # GET /v1/vitals: process readings, operator-only, no store.
104
+ # Both optional — nil means the body simply omits those readings.
105
+ @executor = executor
106
+ @db_path = db_path
107
+ # The deployment's EFFECTIVE tool registry, so the capability view can
108
+ # answer what the agent can reach that can WRITE (the derived
109
+ # eval-profile swap list). Read-only use (names/side_effect?) — same
110
+ # constitutional footing as the profile source. nil = the view omits it.
111
+ @tool_registry = tool_registry
112
+ # The durable MCP instance CRUD (GET/PUT/DELETE
113
+ # /v1/mcp[/:name]) — a direct store read/Command surface, same footing
114
+ # as @profiles/@outcome_store. nil = the routes 404 (parity — a
115
+ # deployment that never wires an McpStore never exposes MCP config
116
+ # over HTTP, same as one that skips provisioning).
117
+ @mcp_store = mcp_store
98
118
  @heartbeat = config.fetch(:heartbeat, 15)
99
119
  @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
100
120
  end
@@ -166,8 +186,20 @@ module Insika
166
186
  handle_trigger_workflow(req, name)
167
187
  in ["POST", ["v1", "responses"]]
168
188
  handle_responses(req)
189
+ in ["POST", ["v1", "outcomes"]] if @outcome_store
190
+ handle_record_outcome(req)
191
+ in ["GET", ["v1", "outcomes"]] if @outcome_store
192
+ handle_list_outcomes(req)
169
193
  in ["POST", ["v1", "tools", "manifest"]]
170
194
  handle_import_tools(req)
195
+ in ["GET", ["v1", "mcp"]] if @mcp_store
196
+ handle_list_mcp
197
+ in ["GET", ["v1", "mcp", name]] if @mcp_store
198
+ handle_read_mcp(name)
199
+ in ["PUT", ["v1", "mcp"]] if @mcp_store
200
+ handle_upsert_mcp(req)
201
+ in ["DELETE", ["v1", "mcp", name]] if @mcp_store
202
+ handle_delete_mcp(req, name)
171
203
  in ["POST", ["v1", "mcp", name, "import"]]
172
204
  handle_import_mcp_tools(req, name)
173
205
  in ["POST", ["v1", "agents"]] if @provisioner
@@ -182,8 +214,12 @@ module Insika
182
214
  handle_read_task(req, id)
183
215
  in ["GET", ["v1", "events"]]
184
216
  handle_events(req)
217
+ in ["GET", ["v1", "vitals"]]
218
+ handle_vitals
185
219
  in ["POST", ["channels", id, "events"]] if @channels
186
220
  handle_channel_event(req, id)
221
+ in ["POST", ["channels", id, "shadow-reply"]] if @channels
222
+ handle_channel_shadow_reply(req, id)
187
223
  in ["POST", ["channels", id, "sessions"]] if @channels
188
224
  handle_channel_session(req, id)
189
225
  in ["POST", ["channels", id, "messages"]] if @channels
@@ -241,7 +277,7 @@ module Insika
241
277
  # tomorrow is gated by default and publishing it is a deliberate edit here.
242
278
  def channel_route?(method, segments)
243
279
  case [method, segments]
244
- in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
280
+ in ["POST", ["channels", _, "events" | "sessions" | "messages" | "shadow-reply"]] then true
245
281
  in ["GET", ["channels", _, "asset", _]] then true
246
282
  in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
247
283
  else false
@@ -259,11 +295,44 @@ module Insika
259
295
  # POST /v1/commands/:type — generic: every new Command is born with a
260
296
  # transport. The control vs turn distinction is BY THE SHAPE of the result (the
261
297
  # transport knows no semantics).
298
+ #
299
+ # The principal's tenant is stamped like on every other surface. It is
300
+ # nil for an operator (this route is operator-only — see TENANT_SURFACES),
301
+ # which is exactly why a tenant-scoped command such as `forget_customer`
302
+ # ALSO reads a `tenant` from its payload: over HTTP the operator names the
303
+ # tenant, because the credential does not carry one.
262
304
  def handle_command(req, type)
263
- command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http)
305
+ command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http,
306
+ tenant: req_tenant(req))
264
307
  command_response(dispatch_with_timeout(command))
265
308
  end
266
309
 
310
+ # POST /v1/outcomes — the operator or the integration records a business
311
+ # outcome for a conversation (WS7). A Control command on the bus, tenant
312
+ # stamped from the principal (WS1) — additive, outside the response
313
+ # contract: the engine transports the outcome, never interprets it.
314
+ # 201 { outcome: { agent, outcome, value, session_id, at } }.
315
+ def handle_record_outcome(req)
316
+ command = Insika::Command.build(:record_outcome, parse_body(req), transport: :http,
317
+ tenant: req_tenant(req))
318
+ record = dispatch_with_timeout(command)
319
+ json_response(201, { outcome: { agent: record.agent, outcome: record.outcome,
320
+ value: record.value, session_id: record.session_id,
321
+ at: record.at } })
322
+ end
323
+
324
+ # GET /v1/outcomes[?agent=] — the Studio scorecard's data: the LAST
325
+ # outcome per agent (state cards) + the per-day series. Tenant-scoped
326
+ # (WS1): a tenant principal reads only its own outcomes.
327
+ def handle_list_outcomes(req)
328
+ tenant = req_tenant(req)
329
+ agent = req.GET["agent"]
330
+ latest = @outcome_store.latest_per_agent(tenant: tenant)
331
+ latest = { agent => latest[agent] }.compact if agent && !agent.empty?
332
+ series = @outcome_store.series(tenant: tenant, agent: agent)
333
+ json_response(200, { latest: latest, series: series })
334
+ end
335
+
267
336
  # POST /v1/sessions — sugar for create_session; 201 {session}.
268
337
  def handle_create_session(req)
269
338
  body = parse_body(req)
@@ -350,6 +419,10 @@ module Insika
350
419
  ensure_session(parsed[:user], tenant: tenant)
351
420
  payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
352
421
  payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
422
+ payload[:customer] = parsed[:customer] if parsed[:customer] # WS8: the memory scope handle
423
+ payload[:parts] = parsed[:parts] if parsed[:parts] # WS9: multimodal content parts
424
+ payload[:source] = parsed[:source] if parsed[:source] # WS9: voice-marked text
425
+ payload[:channel] = parsed[:channel] if parsed[:channel] # WS9 (saída): the channel's output capabilities
353
426
  message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
354
427
  tenant: tenant)
355
428
  end
@@ -384,18 +457,61 @@ module Insika
384
457
  json_response(200, dispatch_with_timeout(command))
385
458
  end
386
459
 
387
- # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
460
+ # GET /v1/mcp — every configured MCP instance, MASKED (@mcp_store.all
461
+ # already masks — never plaintext over the wire). A direct store read,
462
+ # not a Command (constitutional rule at the top of this file); the
463
+ # generic route gate already confined this to the operator (not a
464
+ # TENANT_SURFACES entry), same footing as GET /v1/agents/:id.
465
+ def handle_list_mcp
466
+ json_response(200, { instances: @mcp_store.all })
467
+ end
468
+
469
+ # GET /v1/mcp/:name — one instance, MASKED. Missing -> 404.
470
+ def handle_read_mcp(name)
471
+ record = @mcp_store.get(name)
472
+ raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
473
+
474
+ json_response(200, record)
475
+ end
476
+
477
+ # PUT /v1/mcp — create/edit an instance (the CLI/DSL/Studio
478
+ # config surfaces share this ONE seam: the `:upsert_mcp` Command already
479
+ # registered on the bus). Per-key secret reconciliation (the `__OCULTO__`
480
+ # sentinel round-trips: preserves; a new string replaces; "" clears) —
481
+ # same contract as `Insika::McpJson`. 200 { the MASKED upserted record }.
482
+ def handle_upsert_mcp(req)
483
+ gate = gateway_gate(req)
484
+ return gate if gate
485
+
486
+ command = Insika::Command.build(:upsert_mcp, parse_body(req), transport: :http)
487
+ json_response(200, dispatch_with_timeout(command))
488
+ end
489
+
490
+ # DELETE /v1/mcp/:name — removes an instance (`:delete_mcp`). Idempotent
491
+ # (200 { existed: false } on a name that was never there — never a 404;
492
+ # deleting a thing that is already gone is not an error). The name comes
493
+ # from the ROUTE (data), not the body.
494
+ def handle_delete_mcp(req, name)
495
+ gate = gateway_gate(req)
496
+ return gate if gate
497
+
498
+ command = Insika::Command.build(:delete_mcp, { name: name }, transport: :http)
499
+ json_response(200, dispatch_with_timeout(command))
500
+ end
501
+
502
+ # POST /v1/mcp/:name/import — kept as the alias for the
503
+ # "Refresh tools" action (the route predates the live registry; the name
504
+ # stuck). Dispatches :refresh_mcp_tools — LIVE connect + tools/list +
505
+ # write McpStore#tools_cache, never the retired ingestor snapshot. Same
388
506
  # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
389
- # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
390
- # at the composition root) and ingests them as data-tools (reuses :import_tools:
391
- # upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
392
- # + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
393
- # failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
507
+ # surface. Missing instance -> 404; disabled -> 422; a transport failure
508
+ # surfaces as whatever #call's rescue maps it to. The name comes from the
509
+ # ROUTE (data), not the body.
394
510
  def handle_import_mcp_tools(req, name)
395
511
  gate = gateway_gate(req)
396
512
  return gate if gate
397
513
 
398
- command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
514
+ command = Insika::Command.build(:refresh_mcp_tools, { name: name }, transport: :http)
399
515
  json_response(200, dispatch_with_timeout(command))
400
516
  end
401
517
 
@@ -412,22 +528,50 @@ module Insika
412
528
  # (a client: it never reads a store) can tell "this case cannot run here" from
413
529
  # "this case failed". Deliberately NOT the profile: the prompt,
414
530
  # the model and the guardrail config are none of the caller's business. Just the
415
- # two facts a case declares `requires` against.
531
+ # three facts a case declares `requires` against — plus the side-effect set,
532
+ # which is what an eval profile must swap.
416
533
  #
417
534
  # `tools` is the DECLARED allowlist, and `null` means the agent has an open one
418
535
  # (every registered tool) — the client reads that as "cannot rule anything out"
419
536
  # and runs the case rather than skipping it.
537
+ #
538
+ # `side_effect_tools` is DERIVED here from the deployment's own registry (the
539
+ # effective one the Executor uses): the agent's reachable tools the registry
540
+ # marks `side_effect` (a data-tool's non-GET method, the MCP ingestor's
541
+ # `tools/call`). It is the list a simulated run's eval profile must cover —
542
+ # and it is a fact of the deployment, never a hand-maintained client list.
543
+ # Absent (no registry wired) -> the key is omitted, and the view behaves as
544
+ # before.
420
545
  def handle_read_agent(id)
421
546
  profile = @profiles.fetch(id)
422
547
  raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
423
548
 
424
549
  allow = profile.tools_allow
425
550
  deny = Array(profile.tools_deny).map(&:to_s)
426
- json_response(200, {
427
- id: profile.id,
428
- tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
429
- capabilities: Array(profile.capabilities_declared).map(&:to_s)
430
- })
551
+ payload = {
552
+ id: profile.id,
553
+ # `tools: null` is MEANINGFUL (an open allowlist — the client reads it
554
+ # as "cannot rule anything out"), so it must survive, unlike the new
555
+ # field below.
556
+ tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
557
+ capabilities: Array(profile.capabilities_declared).map(&:to_s)
558
+ }
559
+ derived = side_effect_tools(profile)
560
+ payload[:side_effect_tools] = derived unless derived.nil?
561
+ json_response(200, payload)
562
+ end
563
+
564
+ # The derived side-effect set for an agent: its reachable tools (the same
565
+ # allowlist − deny resolution the `tools` field uses) that the registry marks
566
+ # `side_effect`, sorted for a stable answer. -> [names] | nil (no registry).
567
+ def side_effect_tools(profile)
568
+ return nil unless @tool_registry
569
+
570
+ allowed = if profile.tools_allow.nil? then Array(@tool_registry.names)
571
+ else Array(profile.tools_allow).map(&:to_s)
572
+ end
573
+ denied = Array(profile.tools_deny).map(&:to_s)
574
+ (allowed - denied).select { |name| @tool_registry.side_effect?(name) }.sort
431
575
  end
432
576
 
433
577
  # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
@@ -481,6 +625,8 @@ TENANT_SURFACES = [
481
625
  ["POST", ["v1", "sessions"]],
482
626
  ["POST", ["v1", "messages"]],
483
627
  ["POST", ["v1", "responses"]],
628
+ ["POST", ["v1", "outcomes"]],
629
+ ["GET", ["v1", "outcomes"]],
484
630
  ["POST", ["v1", "workflows", nil]],
485
631
  ["GET", ["v1", "workflows"]],
486
632
  ["GET", ["v1", "sessions", nil]],
@@ -535,7 +681,52 @@ TENANT_SURFACES = [
535
681
 
536
682
  payload = { agent: parsed[:agent], session_id: session_id,
537
683
  message: parsed[:message], event_id: parsed[:event_id] }.compact
538
- channel_ack(payload, transport: :"channel:#{id}")
684
+ ack = channel_ack(payload, transport: :"channel:#{id}")
685
+ # in shadow the mirror's own reply may ride the SAME call
686
+ # (Shape 1) — recorded before we answer, through the one command.
687
+ if channel.respond_to?(:shadow?) && channel.shadow?
688
+ dispatch_shadow_reply(id, parsed) if parsed[:incumbent_reply]
689
+ return shadow_ack(ack)
690
+ end
691
+ ack
692
+ end
693
+
694
+ # POST /channels/:id/shadow-reply — the mirror contract's fallback (Shape 2),
695
+ # for a consumer that mirrors the exchange BEFORE it answers the customer.
696
+ # Same channel_gate (auth is the channel's, not the route's); 404 unless the
697
+ # channel is in shadow — the same parity every other optional surface has.
698
+ def handle_channel_shadow_reply(req, id)
699
+ channel = @channels.find(id)
700
+ return not_found if channel.nil? || !(channel.respond_to?(:shadow?) && channel.shadow?)
701
+
702
+ gate = channel_gate(channel, req)
703
+ return gate if gate
704
+
705
+ parsed = channel.parse_shadow_reply(req, body: parse_raw_body(req)) # ValidationError -> 422
706
+ result = dispatch_shadow_reply(id, parsed)
707
+ json_response(202, { pair_id: result[:pair_id], status: result[:status] })
708
+ end
709
+
710
+ # The incumbent half rides ONE command whatever shape it arrived in, so
711
+ # server/ never writes to a store and both doors share one behaviour.
712
+ def dispatch_shadow_reply(id, parsed)
713
+ @command_bus.dispatch(
714
+ Insika::Command.build(:record_shadow_reply,
715
+ { channel: id.to_s, external_id: parsed[:external_id],
716
+ event_id: parsed[:event_id], reply: parsed[:incumbent_reply] || parsed[:reply],
717
+ at: parsed[:at] }.compact,
718
+ transport: :"channel:#{id}")
719
+ )
720
+ end
721
+
722
+ # 202 -> 200 {task_id, shadow: true}: a consumer wired to "202 means a
723
+ # reply is coming" must not be silently misled. The duplicate/merged/
724
+ # steered verdicts pass through untouched (they already say 200).
725
+ def shadow_ack(ack)
726
+ status, _headers, body = ack
727
+ return ack unless status == 202
728
+
729
+ json_response(200, JSON.parse(body.join).merge("shadow" => true))
539
730
  end
540
731
 
541
732
  # POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
@@ -780,6 +971,36 @@ TENANT_SURFACES = [
780
971
  # happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
781
972
  # #call rescue (becomes an HTTP status).
782
973
  def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
974
+ # WS9: content parts are CONTRACT at the edge — the closed shape is
975
+ # validated here (422) before the command is built; the engine stays
976
+ # lenient for transports that bypass this surface.
977
+ parts = payload[:parts] || payload["parts"]
978
+ if parts && !Insika::Media.well_formed?(parts)
979
+ raise Insika::ValidationError,
980
+ "malformed content part — each part must be {type: text|image|audio|document} with text/url"
981
+ end
982
+ # WS9: the only declared message source is "voice" (pre-transcribed
983
+ # audio) — a consumer that writes prose here is told so, not silently.
984
+ source = payload[:source] || payload["source"]
985
+ if source && source.to_s != "voice"
986
+ raise Insika::ValidationError, 'source must be "voice"'
987
+ end
988
+ # WS9 (saída): the channel's declared OUTPUT media capabilities. The
989
+ # closed list IS the "abstraction admits only what leaks" rule: an
990
+ # unknown value is refused here (422), never silently ignored; a
991
+ # declared value is what the executor may wire (generate_image/tts).
992
+ channel = payload[:channel] || payload["channel"]
993
+ if channel.is_a?(Hash)
994
+ caps = Insika::Media.channel_capabilities(channel)
995
+ unknown = caps - Insika::Media::OUTPUT_CAPABILITIES
996
+ unless unknown.empty?
997
+ raise Insika::ValidationError,
998
+ "unknown channel capability: #{unknown.join(', ')}"
999
+ end
1000
+
1001
+ payload = payload.dup
1002
+ payload[:channel] = { capabilities: caps }
1003
+ end
783
1004
  # WS1: a tenant's session_id is NAMESPACED before the command is built,
784
1005
  # so the turn lands on the tenant's OWN session even when another tenant
785
1006
  # uses the same chat id. The payload the caller sent is untouched.
@@ -993,6 +1214,15 @@ TENANT_SURFACES = [
993
1214
  # touch a store (health cannot fail on IO nor require auth).
994
1215
  def health = json_response(200, { status: "ok" })
995
1216
 
1217
+ # process vitals. Operator-only by construction: not in
1218
+ # PUBLIC_ROUTES (no bearer -> unauthorized) and not in TENANT_SURFACES
1219
+ # (a tenant principal hits the operator-surface gate), so only an
1220
+ # operator reads process internals. Reads no store — pure OS/VM
1221
+ # readings, safe at any rate.
1222
+ def handle_vitals
1223
+ json_response(200, Insika::Vitals.snapshot(executor: @executor, db_path: @db_path))
1224
+ end
1225
+
996
1226
  # Thin Subscription decorator: discards
997
1227
  # events from OTHER tasks and CLOSES the subscription after forwarding the task's
998
1228
  # terminal event. Solves the subscribe-before-task_id gap without touching