insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -178,7 +178,8 @@ module Insika
178
178
  guardrails: c[:guardrails], context_providers: context_providers(spine, c),
179
179
  edge_limiter: c[:edge_limiter],
180
180
  executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
181
- context_trace_store: c[:context_trace_store], llm: @llm }
181
+ context_trace_store: c[:context_trace_store],
182
+ cache_series_store: c[:cache_series_store], llm: @llm }
182
183
  )
183
184
  register_authoring_commands(graph, c)
184
185
  register_workflows(graph)
@@ -229,6 +230,8 @@ module Insika
229
230
  mcp_store: Insika::McpStore.new(config_store: config_store),
230
231
  tool_trace_store: Insika::ToolTraceStore.new(store: backend),
231
232
  context_trace_store: Insika::ContextTraceStore.new(store: backend),
233
+ # the per-AGENT cache-hit series (the Studio agent-detail card).
234
+ cache_series_store: Insika::CacheSeriesStore.new(store: backend),
232
235
  tool_registry: tool_registry,
233
236
  tool_catalog: Insika::ToolCatalog.new(tool_registry: tool_registry),
234
237
  skill_catalog: Insika::SkillCatalog.new([], store: skill_store),
@@ -267,6 +270,7 @@ module Insika
267
270
  bus = graph.bus
268
271
  es = graph.event_stream
269
272
  mem = graph.memory_store
273
+ audit = graph.memory_audit_store
270
274
  bus.register(:create_agent, Insika::Commands::CreateAgent.new(profile_source: c[:profile_source], event_stream: es))
271
275
  bus.register(:update_agent, Insika::Commands::UpdateAgent.new(profile_source: c[:profile_source], event_stream: es))
272
276
  bus.register(:delete_agent, Insika::Commands::DeleteAgent.new(profile_source: c[:profile_source], event_stream: es))
@@ -276,9 +280,11 @@ module Insika
276
280
  bus.register(:restore_agent_file, Insika::Commands::RestoreAgentFile.new(profile_source: c[:profile_source], agent_file_store: c[:agent_file_store], event_stream: es))
277
281
  bus.register(:write_skill, Insika::Commands::WriteSkill.new(skill_store: c[:skill_store], skill_catalog: c[:skill_catalog], event_stream: es))
278
282
  bus.register(:set_skill_agents, Insika::Commands::SetSkillAgents.new(profile_source: c[:profile_source], event_stream: es))
279
- bus.register(:memory_put_fact, Insika::Commands::MemoryPutFact.new(memory_store: mem, event_stream: es))
280
- bus.register(:memory_forget_fact, Insika::Commands::MemoryForgetFact.new(memory_store: mem, event_stream: es))
283
+ bus.register(:memory_put_fact, Insika::Commands::MemoryPutFact.new(memory_store: mem, event_stream: es, audit_store: audit))
284
+ bus.register(:memory_forget_fact, Insika::Commands::MemoryForgetFact.new(memory_store: mem, event_stream: es, audit_store: audit))
281
285
  bus.register(:memory_add_note, Insika::Commands::MemoryAddNote.new(memory_store: mem, event_stream: es))
286
+ # the LGPD access right — the Studio Customers drill exports.
287
+ bus.register(:export_customer_memory, Insika::Commands::ExportCustomerMemory.new(memory_store: mem, event_stream: es))
282
288
  bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
283
289
  bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
284
290
  bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
@@ -31,6 +31,11 @@ module Insika
31
31
  end
32
32
 
33
33
  def run
34
+ # The boot refusal (shadow + no criterion) lives in the AppBuilder; run it
35
+ # BEFORE the Studio is configured so the criterion the Studio folds is the
36
+ # same object the delivery path stamps pairs with.
37
+ @builder.channels?
38
+ register_parity_commands
34
39
  configure_studio
35
40
  dispatch = Rack::URLMap.new("/studio" => Studio::App, "/" => @builder.app)
36
41
  endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
@@ -56,6 +61,23 @@ module Insika
56
61
  def workflows? = @builder.workflows?
57
62
  def channels? = @builder.channels?
58
63
 
64
+ # The parity page renders whenever a shadow channel exists, so the judge
65
+ # button must reach a registered command — the deployment bus registers the
66
+ # same one (config/deployment.rb); a page that answers "unknown command"
67
+ # would make this root a second-class citizen. Registered only when the
68
+ # criterion loaded (a command without one would be a fake judge).
69
+ def register_parity_commands
70
+ criterion = @builder.criterion
71
+ return unless criterion
72
+
73
+ @graph.bus.register(:judge_shadow_pairs,
74
+ Insika::Commands::JudgeShadowPairs.new(
75
+ shadow_pairs: @graph.shadow_pair_store,
76
+ settings_store: @rt.component(:settings_store),
77
+ criterion: criterion, event_stream: @graph.event_stream
78
+ ))
79
+ end
80
+
59
81
  def configure_studio
60
82
  Studio::App.configure(
61
83
  command_bus: @graph.bus, profile_source: @graph.profiles,
@@ -64,16 +86,63 @@ module Insika
64
86
  agent_file_store: @rt.component(:agent_file_store), skill_store: @rt.component(:skill_store),
65
87
  skill_catalog: @graph.skill_catalog, tool_catalog: @graph.tool_catalog,
66
88
  tool_store: @rt.component(:tool_store), memory_store: @graph.memory_store,
89
+ # the Customers drill renders the audit lines.
90
+ memory_audit_store: @graph.memory_audit_store,
67
91
  session_store: @graph.session_store,
68
92
  settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
69
93
  mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
70
94
  tool_trace_store: @rt.component(:tool_trace_store),
71
95
  context_trace_store: @rt.component(:context_trace_store),
96
+ # the per-agent cache-hit series (agent-detail card).
97
+ cache_series_store: @rt.component(:cache_series_store),
72
98
  task_store: @graph.task_store, checkpoint_store: @graph.checkpoint_store,
73
- pending_action_store: @graph.pending_action_store
99
+ pending_action_store: @graph.pending_action_store,
100
+ # WS7: the agents-grid scorecard reads the outcome store.
101
+ outcome_store: @graph.outcome_store,
102
+ # the outcome-funnel page reads the fold's cells and the
103
+ # BudgetLedger's current counters (D6); the only mutation — the
104
+ # baseline freeze — dispatches :freeze_funnel_baseline on the bus.
105
+ funnel_store: @graph.funnel_store,
106
+ budget_ledger: @graph.budget_ledger,
107
+ # the parity page (only rendered when a shadow channel exists —
108
+ # the nav row keys off the registry).
109
+ shadow_pair_store: @graph.shadow_pair_store,
110
+ parity_criterion: @builder.criterion,
111
+ channel_registry: @graph.channel_registry,
112
+ # the Follow-ups page reads the stores directly;
113
+ # its only mutations (cancel, force-revoke) dispatch bus commands.
114
+ followup_store: @graph.followup_store,
115
+ contact_store: @graph.contact_store,
116
+ # the Facts (wiki) page reads the proposal store
117
+ # directly; its mutations dispatch :resolve_proposal on the bus.
118
+ proposal_store: @graph.proposal_store,
119
+ # the Harvest page reads the harvest store + the two
120
+ # pre-registered artifacts directly; its mutations dispatch the
121
+ # harvest bus commands. The criterion/negative list load best-effort
122
+ # (nil = the page renders the refusal/empty states).
123
+ harvest_store: @graph.harvest_store,
124
+ harvest_criterion: harvest_criterion,
125
+ negative_list: harvest_negative_list
74
126
  )
75
127
  end
76
128
 
129
+ # The frozen criterion — best-effort at boot: a bare install
130
+ # without the env var renders the ruler's hole instead of
131
+ # crashing the Studio.
132
+ def harvest_criterion
133
+ path = Insika::EnvSchema.read("INSIKA_HARVEST_CRITERION")
134
+ path && Insika::Harvest::Criterion.load(path)
135
+ rescue Insika::ConfigError, Insika::ValidationError
136
+ nil
137
+ end
138
+
139
+ def harvest_negative_list
140
+ path = Insika::EnvSchema.read("INSIKA_HARVEST_NEGATIVE")
141
+ path && Insika::Harvest::NegativeList.parse(File.read(path))
142
+ rescue Errno::ENOENT
143
+ nil
144
+ end
145
+
77
146
  def persistence
78
147
  @graph.durable? ? "durable (sqlite)" : "ephemeral (memory)"
79
148
  end
data/lib/insika/dsl.rb CHANGED
@@ -215,6 +215,46 @@ module Insika
215
215
  # budget daily: 100_000, monthly: 2_000_000, soft: false
216
216
  def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
217
217
 
218
+ # The outcome funnel declaration: the store's stage vocabulary
219
+ # as pack data — the engine folds WS7 outcome kinds into the DECLARED
220
+ # stages, and never hard-codes one itself. Merges, so repeated calls
221
+ # accumulate (like budget).
222
+ # funnel stages: %w[greeted qualified cart paid],
223
+ # advance_on: { "pix_paid" => "paid", "abandoned_cart" => "cart" },
224
+ # primary: "paid", attribution_window: "72h"
225
+ def funnel(hash) = (@config[:funnel] ||= {}).merge!(hash.transform_keys(&:to_s))
226
+
227
+ # The follow-up declaration: the agent may book a follow-up
228
+ # with a customer at a future time (`schedule` tool); the engine fires the
229
+ # synthetic turn and enforces the policy (quiet hours, max frequency,
230
+ # cancellation keywords, silence detection) at fire time. Pack data —
231
+ # merges, so repeated calls accumulate (like budget).
232
+ # followup arm: "schedule",
233
+ # policy: { quiet_hours: { timezone: "America/Sao_Paulo",
234
+ # start: "21:30", end: "09:00" },
235
+ # max_frequency: "2/24h",
236
+ # cancel_keywords: ["não quero mais contato"],
237
+ # silence_after_sends: 3 }
238
+ def followup(hash) = (@config[:followup] ||= {}).merge!(hash.transform_keys(&:to_s))
239
+
240
+ # The session-distillation declaration: what counts as a fact
241
+ # for THIS store, distilled from finished customer conversations. Pack
242
+ # data — merges, so repeated calls accumulate (like budget). `prompt` and
243
+ # `model` are pack-authored keys the DSL passes through.
244
+ # distill enabled: true, idle_hours: 6, max_proposals: 10
245
+ def distill(hash) = (@config[:distill] ||= {}).merge!(hash.transform_keys(&:to_s))
246
+
247
+ # The gated-harvest declaration: the engine may read this
248
+ # agent's finished traffic and propose SKILLS for the store's playbook —
249
+ # through the negative list, the grounding filter and the double gate,
250
+ # never applied automatically. Pack data — merges, so repeated calls
251
+ # accumulate (like budget). `prompt`/`model` are pack-authored keys the
252
+ # DSL passes through.
253
+ # harvest enabled: true,
254
+ # negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
255
+ # miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
256
+ def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
257
+
218
258
  # Provider-interaction reliability, as DATA (WS3): retries + exponential
219
259
  # backoff on transient failures, a fallback model chain (mid-turn
220
260
  # rotation), and a circuit breaker per (tenant, provider/model) that
@@ -226,17 +266,51 @@ module Insika
226
266
  (@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
227
267
  end
228
268
 
229
- # Operator alert delivery (WS6): POST this agent's budget_warning /
230
- # breaker_open / delivery_failed events to the webhook, as JSON.
231
- # alerts webhook: "https://ops.example.com/insika-alerts"
269
+ # Operator alert delivery (WS6): POST this agent's budget_warning /
270
+ # breaker_open / delivery_failed events to the webhook as JSON.
232
271
  def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
233
272
 
273
+ # Intent routing (WS4): classify each turn's message into one route with a
274
+ # cheap model BEFORE the ask. A Hash: route name -> description (or a Hash
275
+ # with description/delegate/stuck/message), plus the reserved keys
276
+ # "default" (the deterministic fallback) and "model"/"provider" (the cheap
277
+ # classifier). The classifier prompt is generated — data only.
278
+ # routes "shopping" => "the customer wants to browse products",
279
+ # "order" => { "description" => "asks about an existing order",
280
+ # "delegate" => "order-agent" },
281
+ # "human" => { "description" => "the customer asks for a person",
282
+ # "stuck" => true },
283
+ # "default" => "shopping", "model" => "deepseek-v4-flash"
284
+ def routes(hash) = (@config[:routes] ||= {}).merge!(hash.transform_keys(&:to_s))
285
+
234
286
  # The agent may signal it cannot proceed (WS5): when on, the model
235
287
  # can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
236
288
  # message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
237
289
  # stuck_signal true
238
290
  def stuck_signal(on = true) = @config[:stuck_signal] = on
239
291
 
292
+ # The per-session working-state schema this agent keeps and asks for
293
+ # a flat list of field names. [] = off (no provider output,
294
+ # no update_briefing/set_next_step tools).
295
+ # briefing_fields "size", "budget", "delivery_day"
296
+ def briefing_fields(*names)
297
+ @config[:briefing_fields] = names.flatten.map(&:to_s)
298
+ end
299
+
300
+ # Generated-media output policy (WS9, saída): the media kinds this
301
+ # agent MAY generate as turn outputs, with per-kind config. The other
302
+ # half of the gate is the CHANNEL's: the request must declare the
303
+ # matching capability for the tools to exist at all.
304
+ # outputs image: { model: "gpt-image-1", size: "1024x1024" },
305
+ # tts: { model: "tts-1", voice: "alloy" }
306
+ def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
307
+
308
+ # The engine's "Tool discipline" block in the system prompt (retry a
309
+ # weak/empty tool result with a different approach before giving up).
310
+ # ON by default — this setter exists to turn it OFF:
311
+ # tool_persistence false
312
+ def tool_persistence(on = true) = @config[:tool_persistence] = on
313
+
240
314
  # Mechanical tool-result dedupe in the replayed history
241
315
  # (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
242
316
  # SEES: repeated identical tool results collapse to a back-reference.
@@ -250,6 +324,12 @@ module Insika
250
324
  # responses: { "injection" => "I can't help with that." }
251
325
  def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
252
326
 
327
+ # Evidence-grounding policy: the pack declares how the engine
328
+ # polices product claims against the evidence ledger. Same
329
+ # config-over-code shape as `guardrails`; absent = off (parity).
330
+ # grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
331
+ def grounding(hash = {}) = (@config[:grounding] ||= {}).merge!(hash.transform_keys(&:to_s))
332
+
253
333
  # Refinement — how the agent's own instruction files may be
254
334
  # improved from real traffic. Same config-over-code shape as `guardrails`;
255
335
  # omitting it entirely leaves the agent report-only (writes nothing).
@@ -65,8 +65,14 @@ module Insika
65
65
  # the rate-limit reply exactly when the window is saturated. Entry checks
66
66
  # are skipped; the turn's usage still lands on the ledger below.
67
67
  resumed = state.resumed
68
-
69
- if !resumed && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
68
+ # a SCHEDULED turn (the FollowupEngine's kick) skips the
69
+ # ENTRY checks exactly like a resume a follow-up that trips the token
70
+ # ceiling must not receive the rate-limit REPLY (the customer agreed to
71
+ # this message; the volume control is the follow-up policy, not the flood
72
+ # rail). The turn still runs and its usage still lands on the ledger.
73
+ scheduled = scheduled_turn?(state)
74
+
75
+ if !resumed && !scheduled && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
70
76
  breach = check_chat_rate(state, limit, edge)
71
77
  return block(state, edge, **breach) if breach
72
78
  end
@@ -75,7 +81,7 @@ module Insika
75
81
  # `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
76
82
  if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
77
83
  token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
78
- unless resumed
84
+ unless resumed || scheduled
79
85
  spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
80
86
  if spent >= ceiling
81
87
  return block(state, edge, category: :token_ceiling,
@@ -90,9 +96,11 @@ module Insika
90
96
  # typed error (never a customer-facing reply); the alert_at warning and
91
97
  # the SOFT over-cap both warn once per window + inject a context note.
92
98
  # A resumed turn (crash/pause replay) was already admitted: it is never
93
- # refused twice — its spend still lands on the ledger below.
99
+ # refused twice — its spend still lands on the ledger below. A scheduled
100
+ # turn rides the same rule: the follow-up policy is the
101
+ # volume control, not the budget wall.
94
102
  budget_on = budget_configured?(state)
95
- budget_enforce(state) unless resumed
103
+ budget_enforce(state) unless resumed || scheduled
96
104
 
97
105
  result = begin
98
106
  nxt.call(state)
@@ -110,6 +118,14 @@ module Insika
110
118
 
111
119
  private
112
120
 
121
+ # is this turn the FollowupEngine's synthetic kick? The
122
+ # command type is stamped by the engine only — a consumer cannot send it
123
+ # (the SendMessage edge refuses the spelling, C8).
124
+ def scheduled_turn?(state)
125
+ command = state.respond_to?(:task) && state.task&.command
126
+ command.is_a?(Hash) && command["type"].to_s == "scheduled_followup"
127
+ end
128
+
113
129
  # One KV get per turn (same order of cost as the guardrail's config read);
114
130
  # no SettingsStore in the wiring -> per-agent limits only.
115
131
  def platform_edge
@@ -231,12 +247,17 @@ module Insika
231
247
  return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
232
248
  level: level, now: now)
233
249
 
250
+ # `tenant` on the META too, not only in the payload: the tenant-scoped
251
+ # /v1/events subscription filters on meta[:tenant] and is fail-closed, so
252
+ # a warning about the tenant's OWN budget never reached the tenant.
253
+ meta = { task_id: state.task&.id, session_id: state.task&.session_id,
254
+ at: Time.now.utc.iso8601 }
255
+ meta[:tenant] = tenant unless tenant.nil?
234
256
  @event_stream&.emit(Insika::Event.new(
235
257
  type: :budget_warning,
236
258
  data: { agent: agent, tenant: tenant, window: w[:window],
237
259
  spent: spent, cap: w[:cap], level: level },
238
- meta: { task_id: state.task&.id, session_id: state.task&.session_id,
239
- at: Time.now.utc.iso8601 }
260
+ meta: meta
240
261
  ))
241
262
  end
242
263
 
@@ -102,9 +102,9 @@ module Insika
102
102
  spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
103
103
  spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
104
104
  spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
105
- spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
105
+ spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools, channel callbacks and media fetches (default: https only)."),
106
106
  spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
107
- spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
107
+ spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress (media fetches are NOT pinned by it)."),
108
108
  spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
109
109
  spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
110
110
  spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
@@ -115,11 +115,18 @@ module Insika
115
115
  spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
116
116
  spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
117
117
  spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
118
+ spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
119
+ spec(name: "INSIKA_STT_LANGUAGE", description: "Language hint for the transcription of audio message parts (WS9)."),
118
120
  spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
119
121
  spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
120
122
  spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
121
123
  spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
122
124
  spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
125
+ spec(name: "INSIKA_RELAY_SHADOW", type: :boolean, description: "Shadow mode: the relay records replies instead of delivering them."),
126
+ spec(name: "INSIKA_RELAY_DELIVERY", type: :enum, enum: %w[at_end progressive], description: "How the relay flushes the outbox: at_end (one POST) or progressive (one POST per balloon). Unset -> at_end."),
127
+ spec(name: "INSIKA_PARITY_CRITERION", type: :path, description: "The frozen parity criterion file (required in shadow mode)."),
128
+ spec(name: "INSIKA_HARVEST_CRITERION", type: :path, description: "The frozen harvest conversion criterion file (strict-loaded before any promotion)."),
129
+ spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
123
130
  spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
124
131
  spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
125
132
  spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
data/lib/insika/errors.rb CHANGED
@@ -182,6 +182,17 @@ module Insika
182
182
  # tell "the recipient refused" from "the engine has a bug".
183
183
  class DeliveryError < Error; end
184
184
 
185
+ # WS4 routing failed: a route's delegate agent is not configured, or its turn
186
+ # failed. An operator/config error — the envelope names the :routing stage
187
+ # instead of swallowing it as :unknown.
188
+ class RoutingError < Error; end
189
+
190
+ # WS9 media failed: an audio part could not be fetched or transcribed, an
191
+ # image attachment could not be built, or a media URL was egress-blocked. A
192
+ # customer's voice message that never entered the turn must not be silently
193
+ # dropped — the :media stage names it.
194
+ class MediaError < Error; end
195
+
185
196
  # Strict configuration violation (— OpenClaw's config discipline:
186
197
  # "recusa boot com chave desconhecida, no silent config compat"). Raised by
187
198
  # EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
@@ -59,8 +59,9 @@ module Insika
59
59
  module Assertions
60
60
  # Named negative detectors for `must_not` now live in the runtime. Kept as
61
61
  # an alias so any external reference to Evals::Assertions::PII_DETECTORS still
62
- # resolves; the values ARE the runtime's, never a fork.
63
- PII_DETECTORS = Insika::Safety::Detectors::PII
62
+ # resolves; the values ARE the runtime's, never a fork. the
63
+ # pattern data moved to the corpus, still under the same Safety umbrella.
64
+ PII_DETECTORS = Insika::Safety::Corpus::PII
64
65
 
65
66
  # HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING. Declared per
66
67
  # case because it is a per-STORE decision, not a universal rule: sometimes the
@@ -60,12 +60,19 @@ module Insika
60
60
  def compare(golden:, turns:)
61
61
  return nil unless golden.reference?
62
62
 
63
- ours = Pairwise.transcript(golden.user_turns, turns)
64
- theirs = Pairwise.reference_transcript(golden.reference_messages)
65
- return nil if ours.strip.empty?
63
+ compare_texts(ours: Pairwise.transcript(golden.user_turns, turns),
64
+ theirs: Pairwise.reference_transcript(golden.reference_messages),
65
+ vs: golden.human_assisted? ? "human-assisted" : "agent")
66
+ end
67
+
68
+ # two transcripts, no golden — the shadow seam. The judge is
69
+ # the SAME object with the SAME prompt, so shadow pairs and golden cases are
70
+ # graded by one rule. An empty side returns nil (never a verdict against an
71
+ # empty string — that was a bug in the golden path until this seam landed).
72
+ def compare_texts(ours:, theirs:, vs: "agent")
73
+ return nil if ours.to_s.strip.empty? || theirs.to_s.strip.empty?
66
74
 
67
- panel = @asks.map { |ask| judge_once(ask, ours, theirs) }
68
- combine(panel, vs: golden.human_assisted? ? "human-assisted" : "agent")
75
+ combine(@asks.map { |ask| judge_once(ask, ours, theirs) }, vs: vs)
69
76
  end
70
77
 
71
78
  # The replayed conversation as the judge reads it: the user turns we sent,
@@ -81,8 +88,13 @@ module Insika
81
88
  # is the conversation as the customer received it, and telling it "a person wrote
82
89
  # this one" is an invitation to grade the author instead. The fact is carried to
83
90
  # the READER as `vs: human-assisted` instead, which is where it changes a decision.
91
+ # A reference with no text at all returns "" — the caller's empty guard then
92
+ # refuses instead of judging against an empty string .
84
93
  def self.reference_transcript(messages)
85
- Array(messages).map do |m|
94
+ msgs = Array(messages)
95
+ return "" if msgs.all? { |m| m["text"].to_s.strip.empty? }
96
+
97
+ msgs.map do |m|
86
98
  speaker = m["role"].to_s == "user" ? "customer" : "assistant"
87
99
  "#{speaker}: #{m['text'].to_s.strip}"
88
100
  end.join("\n")
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # — the evidence contract (engine half).
7
+ #
8
+ # A tool DECLARES `evidence` in its manifest; the engine then does BOTH jobs
9
+ # from the same declaration: strips the result down to `{items: [{id, line}]}`
10
+ # for the model (the lean envelope), and appends every `id` to the session
11
+ # evidence ledger. No second flag, no "lean but not evidence" mode — a
12
+ # half-configuration cannot exist, which is what keeps "no claim without a
13
+ # tool ID" a tautology at the envelope instead of a convention.
14
+ #
15
+ # Everything here is pure Ruby, no IO: the ToolEnvelope calls it after the real
16
+ # tool returns. It does NOT write anything — the ledger write is the envelope's,
17
+ # via the state.
18
+ module Evidence
19
+ # The declaration (D1/C2.1). Parses three authorable shapes:
20
+ #
21
+ # { "evidence": "products" } // bare kind
22
+ # { "evidence": { "kind": "products" } } // full form
23
+ # { "evidence": { "kind": "products",
24
+ # "items": "results",
25
+ # "attachments": "cards" } } // non-default paths
26
+ #
27
+ # `kind` is the PILOT PACK's value, never a gem constant (the
28
+ # engine owns nothing about product shape).
29
+ Spec = Data.define(:kind, :items_path, :attachments_path) do
30
+ PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
31
+
32
+ # String | Hash | nil -> Spec | nil. Raises ValidationError on a blank kind
33
+ # or an empty/ill-formed path. All at ingestion, never at the turn.
34
+ def self.parse(raw)
35
+ return nil if raw.nil? || raw == false
36
+
37
+ h = raw.is_a?(String) ? { "kind" => raw } : Coercion.deep_stringify(raw)
38
+ h = h.is_a?(Hash) ? h : {}
39
+ kind = Coercion.presence(h["kind"])
40
+ raise Insika::ValidationError, "evidence.kind is required" if kind.nil?
41
+
42
+ items = presence_path(h["items"], "items")
43
+ attachments = presence_path(h["attachments"], "attachments")
44
+ new(kind: kind, items_path: items, attachments_path: attachments)
45
+ end
46
+
47
+ def to_h
48
+ { "kind" => kind, "items" => items_path, "attachments" => attachments_path }.compact
49
+ end
50
+
51
+ def self.presence_path(value, default)
52
+ s = Coercion.presence(value)
53
+ s = default if s.nil?
54
+ raise Insika::ValidationError, "evidence.#{default}: not a dotted path" unless PATH_RE.match?(s)
55
+
56
+ s
57
+ end
58
+ private_class_method :presence_path
59
+ end
60
+
61
+ # The lean result the model sees — the ONLY thing that survives the envelope.
62
+ MAX_ITEMS = 16
63
+ # Line truncation keeps the transcript lean by force (E1).
64
+ LINE_MAX = 200
65
+ # Attachments are a channel nicety, never the answer; bounded on purpose.
66
+ MAX_ATTACHMENTS = 16
67
+ URL_MAX = 500
68
+
69
+ # The attachments contract, validated for the outbox (channel side, never the
70
+ # model): [{ "type" => "card"|"image", "url" => String, "caption" => String|nil }].
71
+ # Entries without a String url, or beyond MAX_ATTACHMENTS, are DROPPED — never
72
+ # a turn failure (the card is a channel nicety, not the answer).
73
+ def self.valid_attachments(list)
74
+ Array(list).filter_map do |entry|
75
+ next unless entry.is_a?(Hash)
76
+
77
+ url = (entry["url"] || entry[:url]).to_s
78
+ next if url.empty?
79
+
80
+ caption = Coercion.presence(entry["caption"] || entry[:caption])
81
+ { "type" => (entry["type"] || entry[:type]).to_s,
82
+ "url" => url[0, URL_MAX],
83
+ "caption" => caption }
84
+ end.first(MAX_ATTACHMENTS)
85
+ end
86
+
87
+ # Result shaping, stateless — callable from any tool-call fiber (the parallel
88
+ # tool_concurrency path). No shared state in this class.
89
+ class Processor
90
+ class << self
91
+ # -> the parsed Hash the evidence paths dig into. For `evidence_envelope`
92
+ # the raw body lives under `__insika_body` (D3); a code tool returns the
93
+ # object itself. Raises JSON::ParserError on a non-JSON envelope body —
94
+ # the envelope turns that into `{error:}`, nothing recorded (fail closed:
95
+ # no IDs, no claims to make).
96
+ def raw(spec, result)
97
+ return result unless result.is_a?(Hash) && result.key?("__insika_body")
98
+
99
+ JSON.parse(result["__insika_body"].to_s)
100
+ end
101
+
102
+ # -> [lean, attachments]. Assumes the shape already passed
103
+ # SchemaGuard.violation_output. Items are capped and lines truncated; the
104
+ # model must never see a null where the contract says items.
105
+ def build(spec, raw)
106
+ items = SchemaGuard.dig(raw, spec.items_path) || []
107
+ lean_items = items.first(MAX_ITEMS).map do |item|
108
+ { "id" => (item["id"] || item[:id]).to_s,
109
+ "line" => Coercion.utf8((item["line"] || item[:line]).to_s)[0, LINE_MAX] }
110
+ end
111
+ lean = { "items" => lean_items }
112
+ attachments = Insika::Evidence.valid_attachments(SchemaGuard.dig(raw, spec.attachments_path))
113
+ [lean, attachments]
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ # The session evidence ledger (C4). A session-scoped SET with an `ungrounded`
120
+ # counter: records every product id that entered the context via an
121
+ # evidence-declared tool this session , plus the ungrounded
122
+ # counter that feeds the daily metric. It is NOT a policy object — it records
123
+ # and answers `ids` / `ungrounded` / `lines`; it never decides.
124
+ class EvidenceLedger
125
+ # Oldest-evicted cap: a session that outlives it needs a real cap or the row
126
+ # grows forever.
127
+ MAX_IDS = 1_000
128
+
129
+ def initialize(store: nil, session_id: nil)
130
+ @store = store
131
+ @session_id = session_id
132
+ @ids = []
133
+ @ungrounded = 0
134
+ end
135
+
136
+ # The in-memory accumulator (the envelope appends, the validator/enforcer
137
+ # read the union). The PERSISTED list is appended on flush (Executor, stage 8)
138
+ # — the envelope never blocks on the store.
139
+ def record(ids)
140
+ @ids.concat(Array(ids).map(&:to_s).reject(&:empty?))
141
+ self
142
+ end
143
+
144
+ # -> the effective set for THIS turn: persisted session evidence (loaded at
145
+ # build) + the turn's new ids, deduped, capped.
146
+ def ids
147
+ (session_ids + @ids).uniq.last(MAX_IDS)
148
+ end
149
+
150
+ attr_reader :ungrounded
151
+
152
+ def ungrounded_count(claim)
153
+ @ungrounded += 1
154
+ claim
155
+ end
156
+
157
+ # -> self, flushed to the session record. Idempotent. A store OR not-found
158
+ # failure is swallowed (evidence is audit — it must never fail a committed
159
+ # turn; a session purged mid-turn by forget_customer/session_purge reads as
160
+ # "nothing to append", never an explosion).
161
+ def flush!
162
+ return self unless @store && @session_id
163
+
164
+ @store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded)
165
+ @ids = []
166
+ @ungrounded = 0
167
+ self
168
+ rescue Insika::Error
169
+ self
170
+ end
171
+
172
+ private
173
+
174
+ def session_ids
175
+ return [] unless @store && @session_id
176
+
177
+ session = @store.find(@session_id)
178
+ Array(session&.evidence&.fetch("ids", [])).map(&:to_s)
179
+ rescue Insika::NotFoundError
180
+ []
181
+ end
182
+ end
183
+ end