insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -3,12 +3,13 @@
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
9
10
  module Channels
10
11
  # The channel for an adopter who ALREADY owns a messaging integration
11
- # (RFC-0011 §6). A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
12
+ # A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
12
13
  # engine for the TURN, not for the platform. Two routes and an envelope —
13
14
  #
14
15
  # consumer --POST /channels/relay/events--> engine acked now, never the reply
@@ -16,7 +17,7 @@ module Insika
16
17
  #
17
18
  # — and everything platform-shaped stays theirs: the 24-hour window, template
18
19
  # approval, media, read receipts, and how markdown becomes WhatsApp formatting
19
- # (§6.3). That is the promise, not the limitation: an integration someone has
20
+ # That is the promise, not the limitation: an integration someone has
20
21
  # already tuned for years does not have to move for them to adopt the engine.
21
22
  # A relay that starts growing template logic has stopped being a relay.
22
23
  #
@@ -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,22 +132,28 @@ 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
- # RFC-0011 §4.3 — the engine namespaces the platform's conversation key, so a
156
+ # the engine namespaces the platform's conversation key, so a
110
157
  # Slack channel id and a phone number can never collide, an operator can see
111
158
  # where a conversation came from, and an id minted for one channel cannot be
112
159
  # used to read another's session.
@@ -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.
@@ -1,4 +1,4 @@
1
- // Insika web widget (RFC-0011 §5.2).
1
+ // Insika web widget.
2
2
  //
3
3
  // One <script> tag, no framework, no build step, no dependency:
4
4
  //
@@ -176,7 +176,7 @@
176
176
  }
177
177
 
178
178
  // The engine issues the session id; we only ever store the one it gave us
179
- // (RFC-0011 §4.3 — a client that proposes its own id on a public endpoint is
179
+ // (a client that proposes its own id on a public endpoint is
180
180
  // one enumeration away from reading someone else's conversation).
181
181
  function session() {
182
182
  var saved = remembered;
@@ -6,8 +6,8 @@ require "securerandom"
6
6
 
7
7
  module Insika
8
8
  module Channels
9
- # The web widget (RFC-0011 §5) — the FIRST Shape A channel, and the adoption
10
- # claim behind the whole RFC: an adopter pastes one tag into their site and has
9
+ # The web widget — the FIRST Shape A channel, and the adoption
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"
@@ -17,7 +17,7 @@ module Insika
17
17
  # no outbox, no claim and no `deliver` here — the three routes and a static
18
18
  # asset are the entire surface:
19
19
  #
20
- # POST /channels/web/sessions mint an opaque session id (the engine's, §4.3)
20
+ # POST /channels/web/sessions mint an opaque session id (the engine's)
21
21
  # POST /channels/web/messages the turn, answered as SSE on this connection
22
22
  # GET /channels/web/asset/widget.js
23
23
  #
@@ -29,7 +29,7 @@ module Insika
29
29
  # · an AGENT allowlist — a visitor addresses the agents the operator published
30
30
  # to the widget, not every agent in the deployment;
31
31
  # · an ORIGIN allowlist — exact match, no wildcards, and no "allow all" value;
32
- # · a mandatory chat RATE LIMIT (§5.4) — the channel answers `:disabled`
32
+ # · a mandatory chat RATE LIMIT — the channel answers `:disabled`
33
33
  # until one is configured, because a public endpoint with an LLM behind it
34
34
  # and no ceiling is an unmetered bill waiting to happen.
35
35
  #
@@ -49,8 +49,8 @@ module Insika
49
49
  # An unversioned URL cannot be cached for a year — the next release would
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
- # (A deliberate narrowing of §5.2's "long-cache versioned URL": the install
53
- # snippet in the RFC has no version in it, so there is nothing to bust.)
52
+ # (A deliberate narrowing of's "long-cache versioned URL": the install
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
@@ -67,7 +67,7 @@ module Insika
67
67
  new(origins: origins, agents: agents, id: id, chat_rate_limit: chat_rate_limit)
68
68
  end
69
69
 
70
- # The probe §5.4's gate needs, asked exactly the way `EdgeLimiter` asks it at
70
+ # The probe's gate needs, asked exactly the way `EdgeLimiter` asks it at
71
71
  # turn time: the per-agent override first, the platform default second. Built
72
72
  # here so both composition roots wire the gate identically, and returned as a
73
73
  # lambda so the channel itself stays store-free.
@@ -149,7 +149,7 @@ module Insika
149
149
  { agent: agent, session_id: session_id, message: message }
150
150
  end
151
151
 
152
- # §4.3's hard rule for a public channel: the ENGINE issues the id and the
152
+ #'s hard rule for a public channel: the ENGINE issues the id and the
153
153
  # client never proposes one. A visitor-supplied session id on an anonymous
154
154
  # endpoint is session hijacking by enumeration, so there is no create-on-write
155
155
  # path — `POST /messages` with an unknown id is a 404, not a new conversation.
@@ -159,7 +159,7 @@ module Insika
159
159
  # protocol: what to type, what to say while a tool runs, and how it ended.
160
160
  #
161
161
  # `:intermediate` and `:thinking` are deliberately absent. `:content` is the
162
- # ANSWER (P19) — the model's narration on the way there is internal, and a
162
+ # ANSWER — the model's narration on the way there is internal, and a
163
163
  # widget that rendered it would show the customer the engine thinking out loud.
164
164
  def frame_for(event)
165
165
  case event.type
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Channels
7
+ # A webhook as a Shape B "channel": the recipient of operator ALERTS
8
+ # (WS6). One instance per configured URL, registered in the ChannelRegistry
9
+ # so ChannelDelivery's outbox+claim+retry pipeline delivers the alert the
10
+ # same way it delivers a chat answer — at-most-once, bounded retry, boot
11
+ # sweep. Deliberately NOT Slack/CRM/anything: it POSTs the event as JSON and
12
+ # the consumer interprets it (the engine transports, it does not integrate).
13
+ #
14
+ # The target URL is operator configuration, so the POST crosses the SAME
15
+ # egress guard the Relay applies: https-only (fails closed), private/
16
+ # loopback/metadata targets and DNS-rebindable hosts blocked. Without it the
17
+ # `alerts.webhook` URL is an SSRF vector — a URL pointed at cloud metadata
18
+ # or an internal API exfiltrates alert events out of the boundary (WS6).
19
+ class Webhook
20
+ def initialize(url, http:, allow_http: false, allow_private: false)
21
+ @http = http
22
+ @allow_http = allow_http
23
+ @allow_private = allow_private
24
+ end
25
+
26
+ # The ChannelDelivery contract: -> HTTP status (200..299 = delivered).
27
+ # Every failure becomes a DeliveryError so the bounded retry records it.
28
+ def deliver(payload, to:, delivery_id: nil)
29
+ if (reason = egress_violation(to))
30
+ raise Insika::DeliveryError, "webhook egress blocked for #{to}: #{reason}"
31
+ end
32
+
33
+ result = @http.request(
34
+ method: :post, url: to,
35
+ headers: { "content-type" => "application/json" },
36
+ body: JSON.generate(payload)
37
+ )
38
+ result[:status].to_i
39
+ rescue Insika::DeliveryError
40
+ raise
41
+ rescue StandardError => e
42
+ raise Insika::DeliveryError, "webhook: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ # Resolved on EVERY delivery, not once at registration: a host that
48
+ # answered a public address yesterday can answer 169.254.169.254 today,
49
+ # and this POST carries the operator's alerts.
50
+ def egress_violation(url)
51
+ Insika::EgressGuard.violation(url, allow_http: @allow_http,
52
+ allow_private: @allow_private)
53
+ rescue URI::InvalidURIError
54
+ "invalid URL"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -13,7 +13,9 @@ module Insika
13
13
  # Executor, injected as the `emit` callable.
14
14
  class ChatBuilder
15
15
  def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
- hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil)
16
+ hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
+ tool_trace_store: nil, media_runner: nil, session_store: nil,
18
+ contact_store: nil, followup_store: nil)
17
19
  @tool_registry = tool_registry
18
20
  @skill_catalog = skill_catalog
19
21
  @checkpoint_store = checkpoint_store
@@ -21,10 +23,25 @@ module Insika
21
23
  @hooks = hooks
22
24
  @tool_catalog = tool_catalog
23
25
  @memory_store = memory_store
24
- # RFC-0010: the object exposing #run_subagent (the Executor). nil = the
26
+ # only to trace load_skill, which is not enveloped — nil = no trace (parity).
27
+ @tool_trace_store = tool_trace_store
28
+ # the object exposing #run_subagent (the Executor). nil = the
25
29
  # spawn_subagent system tool is never wired (parity for a builder used
26
30
  # without delegation, e.g. some unit stubs).
27
31
  @subagent_runner = subagent_runner
32
+ # the Executor as the generate_image/tts runner (seams + usage
33
+ # accounting). nil = the media tools are never wired (parity for stubs).
34
+ @media_runner = media_runner
35
+ # update_briefing / set_next_step are the briefing-write system
36
+ # tools — wired only with @session_store present AND profile.briefing_fields
37
+ # non-empty (double gate, like remember). nil = never wired (parity for a
38
+ # builder used without session persistence, e.g. some unit stubs).
39
+ @session_store = session_store
40
+ # the schedule/cancel_followup system tools — wired only
41
+ # with BOTH stores present AND a parsed follow-up policy on the profile
42
+ # (double gate, like remember). nil = never wired (parity).
43
+ @contact_store = contact_store
44
+ @followup_store = followup_store
28
45
  end
29
46
 
30
47
  # Configures an already-created chat with the context (stage 2) and the
@@ -69,9 +86,16 @@ module Insika
69
86
 
70
87
  # load_skill is a system default (outside the allowlist), otherwise
71
88
  # progressive disclosure breaks. allowed_skills comes from the Resolution
72
- # (policy).
73
- skill_names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
74
- tools << Tools::LoadSkill.new(@skill_catalog, skill_names) unless skill_names.empty?
89
+ # (policy), minus the EAGER ones: their bodies are already in the prompt, so a
90
+ # call could only pay for a duplicate. Nothing lazy left -> the tool is not
91
+ # wired at all. Keeping it for the discretionary skills is deliberate: that
92
+ # call is the only record of which skill the model actually reached for.
93
+ skill_names = lazy_skill_names(state)
94
+ unless skill_names.empty?
95
+ tools << Tools::LoadSkill.new(@skill_catalog, skill_names,
96
+ trace_recorder: @tool_trace_store, state: state,
97
+ agent: state.profile.id)
98
+ end
75
99
 
76
100
  # remember is the memory-write system tool — wired only with
77
101
  # @memory_store present AND profile.memory (a double gate). Never enveloped.
@@ -80,24 +104,75 @@ module Insika
80
104
  event_stream: @event_stream, state: state)
81
105
  end
82
106
 
83
- # spawn_subagent is the delegation system tool (RFC-0010) — wired only with a
107
+ # update_briefing / set_next_step are the briefing-write system tools
108
+ # — wired only with @session_store present AND
109
+ # profile.briefing_fields non-empty (double gate, like remember). Never
110
+ # enveloped: deterministic in-process writes.
111
+ fields = if state.profile.respond_to?(:briefing_fields)
112
+ Array(state.profile.briefing_fields).map(&:to_s)
113
+ else
114
+ []
115
+ end
116
+ if @session_store && !fields.empty?
117
+ tools << Tools::UpdateBriefing.new(session_store: @session_store, fields: fields,
118
+ event_stream: @event_stream, state: state)
119
+ tools << Tools::UpdateBriefing::SetNextStep.new(session_store: @session_store,
120
+ event_stream: @event_stream, state: state)
121
+ end
122
+
123
+ # signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
124
+ # when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
125
+ # deterministic signal; the consumer decides what "stuck" means. Defensive
126
+ # read: a minimal profile double without the reader means off (nil = parity).
127
+ if state.profile.respond_to?(:stuck_signal) && state.profile.stuck_signal
128
+ tools << Tools::StuckSignal.new(state: state)
129
+ end
130
+
131
+ # schedule/cancel_followup are the follow-up system tools —
132
+ # wired only with BOTH stores present AND a parsed follow-up policy on
133
+ # the profile (double gate, like remember). Never enveloped: the store
134
+ # writes are deterministic. The tools stay OUT of the allowlist — they
135
+ # are data-driven per agent, not admittable (prompts/memory/stuck_signal
136
+ # are the precedent).
137
+ if @contact_store && @followup_store && followup_policy(state)
138
+ tools << Tools::ScheduleFollowup.new(contact_store: @contact_store,
139
+ followup_store: @followup_store,
140
+ state: state,
141
+ event_stream: @event_stream)
142
+ tools << Tools::CancelFollowup.new(followup_store: @followup_store,
143
+ state: state,
144
+ event_stream: @event_stream)
145
+ end
146
+
147
+ # generate_image / tts are the generated-media system tools (WS9, saída)
148
+ # — wired only when BOTH gates pass, never enveloped: the AGENT opted in
149
+ # (`profile.outputs` — the per-kind generator config) AND the CHANNEL
150
+ # declared it can receive the media (state.channel_capabilities, from the
151
+ # request's `channel.capabilities`). The "abstraction admits only what
152
+ # leaks" rule, C4: a channel that never declared image_output cannot get
153
+ # a generated image; a profile without `outputs` never generates. The
154
+ # runner is the Executor (seams + usage accounting); nil = no media
155
+ # output at all (parity for a stub builder).
156
+ output_media_tools(state).each { |tool| tools << tool } if @media_runner
157
+
158
+ # spawn_subagent is the delegation system tool — wired only with a
84
159
  # runner present AND profile.subagents non-empty (a double gate, like
85
160
  # remember). Never enveloped: in the synchronous mode the child lives in the
86
161
  # parent's envelope. The runtime gate on WHICH agent is spawnable is the
87
162
  # parent's subagents allowlist, enforced in Executor#run_subagent.
88
163
  if @subagent_runner && !Array(state.profile.subagents).empty?
89
164
  tools << Tools::Subagent.new(runner: @subagent_runner, state: state)
90
- # ...and its parallel sibling (RFC-0010 §A): fan-out N children at once.
165
+ # and its parallel sibling: fan-out N children at once.
91
166
  tools << Tools::Subagents.new(runner: @subagent_runner, state: state)
92
167
  end
93
168
 
94
169
  unless tools.empty?
95
- # Item 30: the ONLY place the gem is told to run tool calls in parallel.
170
+ # the ONLY place the gem is told to run tool calls in parallel.
96
171
  # `:fibers` is not a preference but the only admissible mode — `:threads`
97
172
  # breaks ToolEnvelope's `Async::Task.current.with_timeout`, the SQLite
98
173
  # store's fiber semaphore, and the turn's own durability (mailbox,
99
174
  # approvals, cancellation are all expressed in fiber terms). The number
100
- # the operator configured is OUR cap (D4, ToolAssembly#install_tool_gate);
175
+ # the operator configured is OUR cap (ToolAssembly#install_tool_gate);
101
176
  # the gem has none.
102
177
  if tool_concurrency_for(state)
103
178
  chat.with_tools(*tools, concurrency: :fibers)
@@ -109,9 +184,47 @@ module Insika
109
184
  chat
110
185
  end
111
186
 
187
+ # The skills the model still has to ask for: the Resolution's set minus the eager
188
+ # ones. Intersected by NAME against the catalog's own verdict (SkillCatalog#eager_for)
189
+ # so the tool and the level-1 list can never disagree about who is eager. A catalog
190
+ # without the reader (a unit stub) falls back to the whole set — parity.
191
+ def lazy_skill_names(state)
192
+ names = Array(state.allowed_skills).map { |s| s.respond_to?(:name) ? s.name : s.to_s }
193
+ return names unless @skill_catalog.respond_to?(:eager_for)
194
+
195
+ eager = @skill_catalog.eager_for(state.profile).map(&:name)
196
+ names - eager
197
+ end
198
+
199
+ # the follow-up tools' gate — a PARSED policy on the profile.
200
+ # A malformed declaration reads as "no policy" (the firer blocks it, the
201
+ # doctor reports it; the tools are simply not offered).
202
+ def followup_policy(state)
203
+ followup = state.profile.respond_to?(:followup) ? state.profile.followup : nil
204
+ followup && Insika::FollowupPolicy.parse(followup)
205
+ end
206
+
207
+ # WS9 (saída): the media-output tools this turn may carry, per the double
208
+ # gate (profile outputs ∩ channel capabilities). [] = none. Defensive reads
209
+ # throughout — a minimal profile/state stub is "off", which is the safe
210
+ # parity reading.
211
+ def output_media_tools(state)
212
+ outputs = state.profile.respond_to?(:outputs) ? state.profile.outputs : nil
213
+ return [] unless outputs.is_a?(Hash)
214
+ return [] unless state.respond_to?(:channel_capabilities)
215
+
216
+ caps = Array(state.channel_capabilities).map(&:to_s)
217
+ image_cfg = outputs["image"]
218
+ tts_cfg = outputs["tts"]
219
+ [
220
+ (Tools::GenerateImage.new(runner: @media_runner, config: image_cfg, state: state) if image_cfg.is_a?(Hash) && caps.include?("image_output")),
221
+ (Tools::Tts.new(runner: @media_runner, config: tts_cfg, state: state) if tts_cfg.is_a?(Hash) && caps.include?("audio_output"))
222
+ ].compact
223
+ end
224
+
112
225
  # The turn's effective tool concurrency (nil = serial), plus the ONE thing the
113
226
  # gate owes the operator: when the profile asked for parallel tool calls and
114
- # this turn silently cannot have them (D3 — an approval-required tool would
227
+ # this turn silently cannot have them (an approval-required tool would
115
228
  # deadlock two fibers on the single per-task mailbox), say so once. Otherwise
116
229
  # the speedup just vanishes with no reason given. The rule itself lives in
117
230
  # TurnState; a state predating those readers (a unit stub) means off.
@@ -137,7 +250,7 @@ module Insika
137
250
  ))
138
251
  end
139
252
 
140
- # §11 R3: opt-in Anthropic prompt caching. When the agent enables
253
+ # R3: opt-in Anthropic prompt caching. When the agent enables
141
254
  # prompt_caching AND the resolved provider is Anthropic, wrap the system in
142
255
  # the provider's native Content helper with cache: true — ONE breakpoint at
143
256
  # the END of the system block. By Anthropic's prefix order
@@ -176,7 +289,7 @@ module Insika
176
289
  # History comes from the context/checkpoint. The {role:, content:} shape
177
290
  # tolerates string keys (JSON from the stores). `flatten(1)` dissolves the
178
291
  # Session provider's "eviction units" (an assistant+tool_results cycle grouped
179
- # as one Array, §11 R1) back into a flat message stream.
292
+ # as one Array, R1) back into a flat message stream.
180
293
  #
181
294
  # tool_calls / tool_call_id are rehydrated ONLY when present, so a message
182
295
  # without them keeps the 2-arg shape the specs' FakeChat expects (no unknown
@@ -216,6 +329,14 @@ module Insika
216
329
  tool_calls = 0
217
330
  max_tool_calls = state.profile.limits[:max_tool_calls] || 50
218
331
 
332
+ # the loop detector. Needs #after_message + #add_message for the
333
+ # batch-boundary intervention; a chat without them (smoke shim, minimal
334
+ # double) stays bounded by max_tool_calls alone — never half-wired.
335
+ detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
336
+ repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
337
+ Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
338
+ end
339
+
219
340
  chat.before_tool_call do |tool_call|
220
341
  # call<->decorator correlation (side-effects/skip) — 1st line.
221
342
  state.current_tool_call = tool_call
@@ -224,9 +345,13 @@ module Insika
224
345
  tool_calls += 1
225
346
  if tool_calls > max_tool_calls
226
347
  raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
227
- stage: :tool_limit)
348
+ stage: :tool_limit)
228
349
  end
229
350
 
351
+ # AFTER the count, BEFORE the call runs — a post-warning
352
+ # repeat raises here, so the stubborn loop pays for no extra call.
353
+ detector&.tool_call(tool_call.name, tool_call.arguments)
354
+
230
355
  # :tool pair: RubyLLM's callbacks are additive — the altered subject
231
356
  # feeds later hooks and the events, but does not rewrite the call the
232
357
  # model executes. A hook exception here aborts the turn.
@@ -246,9 +371,16 @@ module Insika
246
371
  end
247
372
 
248
373
  chat.after_tool_result do |result|
374
+ # the RAW result — the only place a Tool::Halt (halt_when) is
375
+ # still recognizable, and a halted batch must receive no intervention.
376
+ detector&.tool_result(result)
249
377
  result = @hooks.run_after(:tool, result)
250
378
  emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
251
379
  end
380
+
381
+ # the intervention appends at the batch boundary (the Nth tool
382
+ # result closing) — never between two tool results of one batch.
383
+ chat.after_message { |message| detector.message_ended(message) } if detector
252
384
  end
253
385
  end
254
386
  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)