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
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require "openssl"
6
+ require "time"
6
7
  require "uri"
7
8
 
8
9
  module Insika
@@ -33,12 +34,25 @@ module Insika
33
34
  DEFAULT_ID = "relay"
34
35
  DEFAULT_TIMEOUT = 10
35
36
 
37
+ # how the outbox flushes for THIS channel. `:at_end` (the
38
+ # default) is one POST with the whole answer, byte-identical to today;
39
+ # `:progressive` lets ChannelDelivery split the answer into balloons and
40
+ # POST them in order (the channel still only translates — it does not know
41
+ # what a balloon is).
42
+ POLICIES = %i[at_end progressive].freeze
43
+
36
44
  attr_reader :id
37
45
 
38
46
  # The bundled relay as an operator configures it: three env vars, of which the
39
47
  # token is the SWITCH — no token, no channel, so there is no way to end up with
40
48
  # this route mounted and open. -> Relay | nil.
41
49
  #
50
+ # `INSIKA_RELAY_SHADOW` (truthy) is the shadow switch: the turn
51
+ # runs end to end and the reply is recorded, never delivered.
52
+ #
53
+ # `INSIKA_RELAY_DELIVERY` ("progressive" | "at_end") is how the outbox
54
+ # flushes. Unset = :at_end.
55
+ #
42
56
  # Shared by every composition root on purpose: the DSL front door has to reach
43
57
  # the same feature as `config.ru`, or the docs are true of only one of them.
44
58
  def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
@@ -48,6 +62,8 @@ module Insika
48
62
  new(inbound_token: token,
49
63
  deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
50
64
  deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
65
+ shadow: Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_RELAY_SHADOW", env)),
66
+ delivery: policy!(Insika::EnvSchema.read("INSIKA_RELAY_DELIVERY", env)),
51
67
  http: http, allow_http: allow_http, allow_private: allow_private)
52
68
  end
53
69
 
@@ -59,9 +75,14 @@ module Insika
59
75
  # reply and the delivery fails loudly instead of silently).
60
76
  # deliver_token: Bearer we send THEM. Optional: a consumer on a private
61
77
  # network may authenticate us another way.
78
+ # shadow: The turn runs, the reply is recorded and never
79
+ # sent. Fail-closed by construction: everything downstream
80
+ # duck-types `shadow?`, so a channel that does not answer it
81
+ # is a normal channel.
82
+ # delivery: How the outbox flushes (:at_end | :progressive).
62
83
  def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
63
84
  id: DEFAULT_ID, allow_http: false, allow_private: false,
64
- timeout: DEFAULT_TIMEOUT)
85
+ timeout: DEFAULT_TIMEOUT, shadow: false, delivery: :at_end)
65
86
  @id = id.to_s
66
87
  @inbound_token = inbound_token.to_s
67
88
  @deliver_url = deliver_url.to_s
@@ -70,10 +91,30 @@ module Insika
70
91
  @allow_http = allow_http
71
92
  @allow_private = allow_private
72
93
  @timeout = timeout
94
+ @shadow = shadow
95
+ @delivery = self.class.policy!(delivery)
96
+ end
97
+
98
+ def shadow? = @shadow
99
+
100
+ def delivery = @delivery
101
+ def progressive? = @delivery == :progressive
102
+
103
+ # "progressive" | "at_end" | blank/unset (= :at_end). An unknown value is
104
+ # a config error at BOOT — the consumer would silently miss every
105
+ # progressive turn, so it is refused where the operator is.
106
+ def self.policy!(value)
107
+ return :at_end if Insika::Coercion.blank?(value)
108
+
109
+ name = value.to_s.strip.downcase.to_sym
110
+ return name if POLICIES.include?(name)
111
+
112
+ raise Insika::ConfigError,
113
+ "unknown relay delivery: #{value.inspect} (expected #{POLICIES.join(', ')})"
73
114
  end
74
115
 
75
116
  # -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
76
- # RFC sketched one): a status code is the transport's vocabulary, and keeping
117
+ # the design sketched one): a status code is the transport's vocabulary, and keeping
77
118
  # it out of here is what lets this class be tested without Rack and read
78
119
  # without knowing HTTP.
79
120
  def authenticate(req)
@@ -91,19 +132,25 @@ module Insika
91
132
  # { "agent": "support", "external_id": "5511999998888",
92
133
  # "event_id": "wamid.HBg…", "message": "queria saber do pedido",
93
134
  # "vars": { … } }
135
+ #
136
+ # In SHADOW mode `event_id` is REQUIRED: it is the correlation key both
137
+ # halves of the pair are built from, and a mirror that cannot supply a
138
+ # stable id cannot be paired.
94
139
  def parse(_req, body:)
95
140
  body = body.is_a?(Hash) ? body : {}
96
141
  agent = string(body["agent"])
97
142
  external_id = string(body["external_id"])
98
143
  message = string(body["message"])
144
+ event_id = presence(body["event_id"])
99
145
 
100
146
  raise Insika::ValidationError, "agent is required" if agent.empty?
101
147
  raise Insika::ValidationError, "external_id is required" if external_id.empty?
102
148
  raise Insika::ValidationError, "message is required" if message.strip.empty?
149
+ raise Insika::ValidationError, "event_id is required in shadow mode" if @shadow && event_id.nil?
103
150
 
104
151
  vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
105
152
  { agent: agent, external_id: external_id, message: message,
106
- event_id: presence(body["event_id"]), vars: vars }
153
+ event_id: event_id, vars: vars, incumbent_reply: presence(body["incumbent_reply"]) }
107
154
  end
108
155
 
109
156
  # the engine namespaces the platform's conversation key, so a
@@ -127,6 +174,8 @@ module Insika
127
174
  # consumer that receives the same delivery twice (we retried after a timeout
128
175
  # that actually landed) can drop the second one.
129
176
  def deliver(payload, to:, delivery_id: nil)
177
+ raise Insika::DeliveryError, "relay '#{@id}' is in shadow mode and must never deliver" if @shadow
178
+
130
179
  raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
131
180
 
132
181
  if (reason = egress_violation)
@@ -143,8 +192,33 @@ module Insika
143
192
  raise Insika::DeliveryError, "#{e.class}: #{e.message}"
144
193
  end
145
194
 
195
+ # The reply, as recorded by the mirror. Follows the
196
+ # same strictness as `parse`; `at` is optional (nil = now).
197
+ def parse_shadow_reply(_req, body:)
198
+ body = body.is_a?(Hash) ? body : {}
199
+ external_id = string(body["external_id"])
200
+ event_id = presence(body["event_id"])
201
+ reply = string(body["reply"])
202
+
203
+ raise Insika::ValidationError, "external_id is required" if external_id.empty?
204
+ raise Insika::ValidationError, "event_id is required" if event_id.nil?
205
+ raise Insika::ValidationError, "reply is required" if reply.strip.empty?
206
+
207
+ at = presence(body["at"])
208
+ raise Insika::ValidationError, "at must be an ISO8601 timestamp" if at && !parseable_time?(at)
209
+
210
+ { external_id: external_id, event_id: event_id, reply: reply, at: at }
211
+ end
212
+
146
213
  private
147
214
 
215
+ def parseable_time?(value)
216
+ Time.iso8601(value)
217
+ true
218
+ rescue ArgumentError
219
+ false
220
+ end
221
+
148
222
  # Resolved on EVERY call, not once at boot: a hostname that answered a public
149
223
  # address yesterday can answer 169.254.169.254 today, and this POST carries
150
224
  # the customer's conversation.
@@ -7,7 +7,7 @@ require "securerandom"
7
7
  module Insika
8
8
  module Channels
9
9
  # The web widget — the FIRST Shape A channel, and the adoption
10
- # claim behind the whole RFC: an adopter pastes one tag into their site and has
10
+ # claim behind the whole design: an adopter pastes one tag into their site and has
11
11
  # a working agent, with no backend of their own and no build step.
12
12
  #
13
13
  # <script src="https://agents.example.com/channels/web/asset/widget.js"
@@ -50,7 +50,7 @@ module Insika
50
50
  # never reach a browser that already has it. Short max-age + an ETag instead:
51
51
  # the common case is a 304 with no body, and an upgrade lands within minutes.
52
52
  # (A deliberate narrowing of's "long-cache versioned URL": the install
53
- # snippet in the RFC has no version in it, so there is nothing to bust.)
53
+ # snippet has no version in it, so there is nothing to bust.)
54
54
  ASSET_CACHE_CONTROL = "public, max-age=300"
55
55
 
56
56
  attr_reader :id
@@ -14,7 +14,8 @@ module Insika
14
14
  class ChatBuilder
15
15
  def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
16
  hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
- tool_trace_store: nil)
17
+ tool_trace_store: nil, media_runner: nil, session_store: nil,
18
+ contact_store: nil, followup_store: nil, knowledge_store: nil)
18
19
  @tool_registry = tool_registry
19
20
  @skill_catalog = skill_catalog
20
21
  @checkpoint_store = checkpoint_store
@@ -22,12 +23,29 @@ module Insika
22
23
  @hooks = hooks
23
24
  @tool_catalog = tool_catalog
24
25
  @memory_store = memory_store
26
+ # load_knowledge is the knowledge-read system tool — wired only with
27
+ # @knowledge_store present AND profile.knowledge["retrieve"] (a double
28
+ # gate, like remember). nil = never wired (parity).
29
+ @knowledge_store = knowledge_store
25
30
  # only to trace load_skill, which is not enveloped — nil = no trace (parity).
26
31
  @tool_trace_store = tool_trace_store
27
32
  # the object exposing #run_subagent (the Executor). nil = the
28
33
  # spawn_subagent system tool is never wired (parity for a builder used
29
34
  # without delegation, e.g. some unit stubs).
30
35
  @subagent_runner = subagent_runner
36
+ # the Executor as the generate_image/tts runner (seams + usage
37
+ # accounting). nil = the media tools are never wired (parity for stubs).
38
+ @media_runner = media_runner
39
+ # update_briefing / set_next_step are the briefing-write system
40
+ # tools — wired only with @session_store present AND profile.briefing_fields
41
+ # non-empty (double gate, like remember). nil = never wired (parity for a
42
+ # builder used without session persistence, e.g. some unit stubs).
43
+ @session_store = session_store
44
+ # the schedule/cancel_followup system tools — wired only
45
+ # with BOTH stores present AND a parsed follow-up policy on the profile
46
+ # (double gate, like remember). nil = never wired (parity).
47
+ @contact_store = contact_store
48
+ @followup_store = followup_store
31
49
  end
32
50
 
33
51
  # Configures an already-created chat with the context (stage 2) and the
@@ -90,6 +108,31 @@ module Insika
90
108
  event_stream: @event_stream, state: state)
91
109
  end
92
110
 
111
+ # load_knowledge is a system default (outside the allowlist), like
112
+ # load_skill — wired only with @knowledge_store present AND the
113
+ # profile's opt-in (double gate, like remember). Reads the same
114
+ # (agent, tenant) scope the Knowledge context provider searches.
115
+ if @knowledge_store && Coercion.truthy?(state.profile.knowledge&.dig("retrieve"))
116
+ tools << Tools::LoadKnowledge.new(@knowledge_store, state.profile.id, tenant: state.tenant,
117
+ trace_recorder: @tool_trace_store, state: state)
118
+ end
119
+
120
+ # update_briefing / set_next_step are the briefing-write system tools
121
+ # — wired only with @session_store present AND
122
+ # profile.briefing_fields non-empty (double gate, like remember). Never
123
+ # enveloped: deterministic in-process writes.
124
+ fields = if state.profile.respond_to?(:briefing_fields)
125
+ Array(state.profile.briefing_fields).map(&:to_s)
126
+ else
127
+ []
128
+ end
129
+ if @session_store && !fields.empty?
130
+ tools << Tools::UpdateBriefing.new(session_store: @session_store, fields: fields,
131
+ event_stream: @event_stream, state: state)
132
+ tools << Tools::UpdateBriefing::SetNextStep.new(session_store: @session_store,
133
+ event_stream: @event_stream, state: state)
134
+ end
135
+
93
136
  # signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
94
137
  # when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
95
138
  # deterministic signal; the consumer decides what "stuck" means. Defensive
@@ -98,6 +141,33 @@ module Insika
98
141
  tools << Tools::StuckSignal.new(state: state)
99
142
  end
100
143
 
144
+ # schedule/cancel_followup are the follow-up system tools —
145
+ # wired only with BOTH stores present AND a parsed follow-up policy on
146
+ # the profile (double gate, like remember). Never enveloped: the store
147
+ # writes are deterministic. The tools stay OUT of the allowlist — they
148
+ # are data-driven per agent, not admittable (prompts/memory/stuck_signal
149
+ # are the precedent).
150
+ if @contact_store && @followup_store && followup_policy(state)
151
+ tools << Tools::ScheduleFollowup.new(contact_store: @contact_store,
152
+ followup_store: @followup_store,
153
+ state: state,
154
+ event_stream: @event_stream)
155
+ tools << Tools::CancelFollowup.new(followup_store: @followup_store,
156
+ state: state,
157
+ event_stream: @event_stream)
158
+ end
159
+
160
+ # generate_image / tts are the generated-media system tools (WS9, saída)
161
+ # — wired only when BOTH gates pass, never enveloped: the AGENT opted in
162
+ # (`profile.outputs` — the per-kind generator config) AND the CHANNEL
163
+ # declared it can receive the media (state.channel_capabilities, from the
164
+ # request's `channel.capabilities`). The "abstraction admits only what
165
+ # leaks" rule, C4: a channel that never declared image_output cannot get
166
+ # a generated image; a profile without `outputs` never generates. The
167
+ # runner is the Executor (seams + usage accounting); nil = no media
168
+ # output at all (parity for a stub builder).
169
+ output_media_tools(state).each { |tool| tools << tool } if @media_runner
170
+
101
171
  # spawn_subagent is the delegation system tool — wired only with a
102
172
  # runner present AND profile.subagents non-empty (a double gate, like
103
173
  # remember). Never enveloped: in the synchronous mode the child lives in the
@@ -139,6 +209,32 @@ module Insika
139
209
  names - eager
140
210
  end
141
211
 
212
+ # the follow-up tools' gate — a PARSED policy on the profile.
213
+ # A malformed declaration reads as "no policy" (the firer blocks it, the
214
+ # doctor reports it; the tools are simply not offered).
215
+ def followup_policy(state)
216
+ followup = state.profile.respond_to?(:followup) ? state.profile.followup : nil
217
+ followup && Insika::FollowupPolicy.parse(followup)
218
+ end
219
+
220
+ # WS9 (saída): the media-output tools this turn may carry, per the double
221
+ # gate (profile outputs ∩ channel capabilities). [] = none. Defensive reads
222
+ # throughout — a minimal profile/state stub is "off", which is the safe
223
+ # parity reading.
224
+ def output_media_tools(state)
225
+ outputs = state.profile.respond_to?(:outputs) ? state.profile.outputs : nil
226
+ return [] unless outputs.is_a?(Hash)
227
+ return [] unless state.respond_to?(:channel_capabilities)
228
+
229
+ caps = Array(state.channel_capabilities).map(&:to_s)
230
+ image_cfg = outputs["image"]
231
+ tts_cfg = outputs["tts"]
232
+ [
233
+ (Tools::GenerateImage.new(runner: @media_runner, config: image_cfg, state: state) if image_cfg.is_a?(Hash) && caps.include?("image_output")),
234
+ (Tools::Tts.new(runner: @media_runner, config: tts_cfg, state: state) if tts_cfg.is_a?(Hash) && caps.include?("audio_output"))
235
+ ].compact
236
+ end
237
+
142
238
  # The turn's effective tool concurrency (nil = serial), plus the ONE thing the
143
239
  # gate owes the operator: when the profile asked for parallel tool calls and
144
240
  # this turn silently cannot have them (an approval-required tool would
@@ -279,9 +375,16 @@ module Insika
279
375
  # under concurrency `after_tool_result` labelled every result with whichever
280
376
  # call started last.
281
377
  state.current_tool_name = tool_call.name.to_s
282
- if state.current_tool_name == "load_skill"
378
+ case state.current_tool_name
379
+ when "load_skill"
283
380
  args = tool_call.arguments || {}
284
381
  emit.call(:skill_activated, { name: args["name"] || args[:name] })
382
+ when "load_knowledge"
383
+ # The adoption risk is the model never calling this tool, not what
384
+ # it injects — so the metric here is retrieval CALLS, tracked at
385
+ # the same point :skill_activated is.
386
+ args = tool_call.arguments || {}
387
+ emit.call(:knowledge_retrieved, { name: args["name"] || args[:name], agent: state.profile.id })
285
388
  else
286
389
  emit.call(:tool_call, { name: tool_call.name, arguments: tool_call.arguments })
287
390
  end
@@ -109,6 +109,22 @@ module Insika
109
109
  nil
110
110
  end
111
111
 
112
+ # Deletes EVERY checkpoint + side-effect record of the task (WS8 retention:
113
+ # a purged task's durability trail goes with it — no orphaned keys).
114
+ # -> count of records removed.
115
+ def purge(task_id)
116
+ removed = 0
117
+ checkpoint_turns(task_id).each do |n|
118
+ @store.delete(SCOPE, checkpoint_key(task_id, n))
119
+ removed += 1
120
+ end
121
+ sideeffect_turns(task_id).each do |n|
122
+ @store.delete(SCOPE, sideeffects_key(task_id, n))
123
+ removed += 1
124
+ end
125
+ removed
126
+ end
127
+
112
128
  private
113
129
 
114
130
  def checkpoint_key(task_id, turn)
@@ -12,10 +12,10 @@ module Insika
12
12
  FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
13
13
  tools_allow_groups skills skills_eager context_providers workflows_allow policies
14
14
  prompt_refs limits approvals_required capabilities subagents tools_deferred
15
- memory prompt_caching tool_output_compression budget reliability alerts
16
- stuck_signal
15
+ memory prompt_caching tool_persistence tool_output_compression budget reliability alerts
16
+ routes stuck_signal outputs stt_prompt briefing_fields grounding funnel followup
17
17
  params model_policy guardrails refinement capabilities_declared
18
- edge_stream metadata].freeze
18
+ edge_stream metadata distill harvest knowledge schedules].freeze
19
19
 
20
20
  module_function
21
21
 
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # The recovery path (extraction is best-effort — a crash between a turn's
8
+ # terminal and the write loses that turn's concepts, not the conversation
9
+ # nor a previously learned one). Replays one agent's stored sessions
10
+ # through the SAME `Knowledge::Extractor` the per-turn hook uses.
11
+ #
12
+ # Synchronous — the CLI's own path (`insika knowledge:backfill`), same
13
+ # "no app boot" discipline as RunHarvest/RunDistillation: no task, no
14
+ # turn, nothing written but the concepts themselves.
15
+ class BackfillKnowledge
16
+ DEFAULT_MIN_MESSAGES = 2
17
+
18
+ def initialize(profiles:, knowledge_store:, session_store:, task_store:,
19
+ settings_store: nil, event_stream:, extractor_factory: nil, consolidator_factory: nil)
20
+ @profiles = ProfileSource.coerce(profiles)
21
+ @knowledge_store = knowledge_store
22
+ @session_store = session_store
23
+ @task_store = task_store
24
+ @settings_store = settings_store
25
+ @extractor_factory = extractor_factory ||
26
+ ->(config) { Knowledge::ExtractorFactory.build(config, utility_model: utility_model) }
27
+ @consolidator_factory = consolidator_factory ||
28
+ ->(config) { Knowledge::ConsolidatorFactory.build(config, utility_model: utility_model) }
29
+ @event_stream = event_stream
30
+ end
31
+
32
+ # payload: { agent:, since?: ISO8601 }
33
+ # -> { backfilled: true, sessions: N, concepts: N, conflicts: N, dropped: {...} }
34
+ # | { backfilled: false, skipped: "disabled|no_model|no_sessions" }
35
+ def call(command)
36
+ p = AgentPayload.symbolize(command.payload)
37
+ agent = AgentPayload.presence(p[:agent])
38
+ raise Insika::ValidationError, "agent is required" if agent.nil?
39
+
40
+ profile = @profiles[agent] || (raise Insika::NotFoundError, "agent '#{agent}' not configured")
41
+ config = Coercion.deep_stringify(profile.knowledge)
42
+ return skip("disabled") if config.nil? || !Coercion.truthy?(config["extract"])
43
+
44
+ extractor = @extractor_factory.call(config)
45
+ return skip("no_model") if extractor.nil?
46
+
47
+ # Same resolved model as the extractor. nil (unresolvable) is still
48
+ # meaningful: Knowledge.write_concept's conservative default.
49
+ consolidator = @consolidator_factory.call(config)
50
+
51
+ sessions = resolve_sessions(agent, p)
52
+ return skip("no_sessions") if sessions.empty?
53
+
54
+ concepts = 0
55
+ conflicts = 0
56
+ dropped = Hash.new(0)
57
+ sessions.each do |s|
58
+ result = extractor.extract(prompt: build_prompt(config, s[:messages]))
59
+ result[:concepts].each do |c|
60
+ case write_concept(profile, s[:id], c, consolidator)[:verdict]
61
+ when :new, :related then concepts += 1
62
+ when :contradicting then conflicts += 1
63
+ end
64
+ end
65
+ result[:dropped].each { |k, v| dropped[k] += v }
66
+ end
67
+
68
+ emit(:knowledge_backfilled, agent: agent, sessions: sessions.size, concepts: concepts,
69
+ conflicts: conflicts, dropped: dropped.to_h)
70
+ { backfilled: true, sessions: sessions.size, concepts: concepts, conflicts: conflicts, dropped: dropped.to_h }
71
+ end
72
+
73
+ private
74
+
75
+ def skip(reason) = { backfilled: false, skipped: reason }
76
+
77
+ # Every task of the agent, distinct session ids, newest first — the
78
+ # same reading `RunHarvest#window_session_ids` uses, without the
79
+ # last-N cap (a backfill is meant to be exhaustive over `--since`).
80
+ def resolve_sessions(agent, p)
81
+ since = AgentPayload.presence(p[:since])
82
+ tasks = @task_store.each_id.filter_map { |id| @task_store.find(id) }
83
+ .select { |t| task_agent(t) == agent }
84
+ .sort_by { |t| [t.created_at.to_s, t.id] }.reverse
85
+ tasks = tasks.select { |t| t.created_at.to_s >= since.to_s } if since
86
+
87
+ seen = {}
88
+ tasks.filter_map do |t|
89
+ sid = Coercion.presence(t.session_id)
90
+ next if sid.nil? || seen[sid]
91
+
92
+ seen[sid] = true
93
+ session = @session_store.find(sid)
94
+ next unless session && session.messages.size >= DEFAULT_MIN_MESSAGES
95
+
96
+ { id: sid.to_s, messages: session.messages }
97
+ end
98
+ end
99
+
100
+ def task_agent(task)
101
+ task.command.is_a?(Hash) ? task.command.dig("payload", "agent").to_s : ""
102
+ end
103
+
104
+ def build_prompt(config, messages)
105
+ base = Coercion.presence(config["prompt"]) || Knowledge::DEFAULT_PROMPT
106
+ <<~PROMPT
107
+ #{base.rstrip}
108
+
109
+ ## The conversation
110
+
111
+ #{render_transcript(messages)}
112
+ PROMPT
113
+ end
114
+
115
+ def render_transcript(messages)
116
+ redacted, = Insika::Safety::Detectors.redact(
117
+ messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
118
+ )
119
+ redacted
120
+ end
121
+
122
+ def write_concept(profile, session_id, concept, consolidator)
123
+ outcome = Knowledge.write_concept(store: @knowledge_store, agent_id: profile.id, concept: concept,
124
+ session_id: session_id, consolidator: consolidator)
125
+ case outcome[:verdict]
126
+ when :new, :related
127
+ emit(:knowledge_learned, name: outcome[:name], type: outcome[:type], agent: profile.id)
128
+ when :contradicting
129
+ emit(:knowledge_conflict, name: outcome[:name], agent: profile.id)
130
+ end
131
+ outcome
132
+ end
133
+
134
+ def utility_model
135
+ return nil unless @settings_store
136
+
137
+ @settings_store.get["utility_model"]
138
+ end
139
+
140
+ def emit(type, **data)
141
+ @event_stream.emit(Insika::Event.new(type: type, data: data, meta: { at: Time.now.utc.iso8601 }))
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # the operator cancels ONE pending follow-up record (the
8
+ # Studio's Cancel button dispatches this). Synchronous control command (no
9
+ # task, like RecordOutcome); idempotent — an already-cancelled record
10
+ # returns as-is (a repeat of the same call is not an error). Tenant-scoped
11
+ # (WS1): a tenant principal can only cancel its own tenant's records.
12
+ class CancelFollowup
13
+ def initialize(followup_store:, event_stream:)
14
+ @followup_store = followup_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ # -> Record. Emits :followup_cancelled { id:, cancelled_by: }.
19
+ def call(command)
20
+ id = Coercion.presence(command.payload[:followup_id] || command.payload["followup_id"])
21
+ raise ValidationError, "followup_id is required" if id.nil?
22
+
23
+ record = @followup_store.find(id)
24
+ raise NotFoundError, "follow-up not found: #{id}" if record.nil?
25
+
26
+ # WS1: the principal's tenant is the scope — a tenant token cannot
27
+ # cancel another tenant's record by id.
28
+ tenant = command.meta[:tenant]
29
+ unless record.tenant == (tenant.to_s.empty? ? "platform" : tenant.to_s)
30
+ raise ValidationError, "follow-up #{id} belongs to another tenant"
31
+ end
32
+
33
+ # A non-pending record (the tick fired it between the render and the
34
+ # click) is a domain error the Studio can flash — never a 500.
35
+ cancelled = begin
36
+ @followup_store.cancel(id: id)
37
+ rescue ArgumentError => e
38
+ raise ValidationError, e.message
39
+ end
40
+ @event_stream.emit(Insika::Event.new(
41
+ type: :followup_cancelled,
42
+ data: { id: cancelled.id, cancelled_by: "operator" },
43
+ meta: { tenant: command.meta[:tenant], at: Time.now.utc.iso8601 }
44
+ ))
45
+ cancelled
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # The Studio's delete on the Artifacts tab — a bus command, like every
8
+ # Studio mutation (the Studio never writes a store directly). Idempotent:
9
+ # an unknown id is a no-op, not an error.
10
+ class DeleteArtifact
11
+ def initialize(artifact_store:, event_stream:)
12
+ @artifact_store = artifact_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ # -> { deleted: id } | { deleted: nil } (unknown id).
17
+ def call(command)
18
+ id = Coercion.presence(command.payload[:id] || command.payload["id"])
19
+ raise ValidationError, "id is required" if id.nil?
20
+
21
+ deleted = @artifact_store.delete(id.to_s)
22
+ if deleted
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :artifact_deleted,
25
+ data: { id: id.to_s },
26
+ meta: { at: Time.now.utc.iso8601 }
27
+ ))
28
+ { deleted: id.to_s }
29
+ else
30
+ { deleted: nil }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes a learned concept from the KnowledgeStore.
8
+ # -> { name, agent, tenant, deleted: true }.
9
+ class DeleteConcept
10
+ def initialize(knowledge_store:, event_stream:)
11
+ @knowledge_store = knowledge_store
12
+ @event_stream = event_stream
13
+ end
14
+
15
+ def call(command)
16
+ p = AgentPayload.symbolize(command.payload)
17
+ name = AgentPayload.presence(p[:name])
18
+ agent = AgentPayload.presence(p[:agent])
19
+ tenant = AgentPayload.presence(p[:tenant])
20
+ raise Insika::ValidationError, "name is required" if name.nil?
21
+ raise Insika::ValidationError, "agent is required" if agent.nil?
22
+
23
+ deleted = @knowledge_store.delete(agent, name, tenant: tenant)
24
+ raise Insika::NotFoundError, "concept '#{name}' not found for agent '#{agent}'" unless deleted
25
+
26
+ @event_stream.emit(Insika::Event.new(
27
+ type: :knowledge_deleted, data: { name: name, agent: agent },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ { name: name, agent: agent, tenant: tenant, deleted: true }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -5,11 +5,14 @@ require "time"
5
5
  module Insika
6
6
  module Commands
7
7
  # Control command: removes an MCP instance from the
8
- # McpStore. Idempotent (`existed: false` if there was none). -> { existed: bool }.
8
+ # McpStore. Idempotent (`existed: false` if there was none). Evicts any
9
+ # memoized client for this name — a removed instance stops answering
10
+ # tool calls right away, not after a restart. -> { existed: bool }.
9
11
  class DeleteMcp
10
- def initialize(mcp_store:, event_stream:)
12
+ def initialize(mcp_store:, event_stream:, mcp_registry: nil)
11
13
  @mcp_store = mcp_store
12
14
  @event_stream = event_stream
15
+ @mcp_registry = mcp_registry
13
16
  end
14
17
 
15
18
  def call(command)
@@ -18,6 +21,7 @@ module Insika
18
21
  raise Insika::ValidationError, "name is required" if name.nil?
19
22
 
20
23
  existed = @mcp_store.delete(name)
24
+ @mcp_registry&.evict(name)
21
25
  @event_stream.emit(Insika::Event.new(
22
26
  type: :mcp_deleted,
23
27
  data: { name: name, existed: existed },