insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Context
7
+ module Providers
8
+ # The read path of a customer confirmation: the calls the envelope held on
9
+ # an earlier turn of this session, rendered at the TAIL of the context —
10
+ # right before the message that answers them — so the last thing the model
11
+ # reads before replying is the question the customer is replying to. Zero
12
+ # bytes when nothing is open, which is every turn of most conversations.
13
+ class PendingConfirmation < ContextProvider
14
+ def initialize(pending_action_store:)
15
+ @store = pending_action_store
16
+ end
17
+
18
+ def id = "pending_confirmation"
19
+
20
+ # Pack-declared: no customer_confirm -> no provider.
21
+ def enabled_for?(profile)
22
+ list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
23
+ !Array(list).empty?
24
+ end
25
+
26
+ def allowlisted? = false # `customer_confirm` is the opt-in, not the allowlist
27
+
28
+ def call(request)
29
+ session = request.respond_to?(:session) ? request.session : nil
30
+ return [] if session.nil? || @store.nil?
31
+
32
+ open = @store.open_for_session(session.id, kind: PendingActionStore::CUSTOMER)
33
+ return [] if open.empty?
34
+
35
+ # A tail fragment IS a history message ({role:, content:}), like the
36
+ # Briefing's recitation — the Builder appends it after the transcript.
37
+ [ContextFragment.build(content: { role: :user, content: render(open) }, placement: :tail,
38
+ pinned: true, priority: Context::Priority::PENDING_CONFIRMATION, source: id)]
39
+ end
40
+
41
+ private
42
+
43
+ def render(open)
44
+ lines = open.map { |pa| "- #{pa.tool} #{JSON.generate(pa.args || {})} — pending_id #{pa.id}" }
45
+ "## Awaiting the customer's confirmation\n#{lines.join("\n")}\n" \
46
+ "Call confirm_pending only if the customer's message agrees to exactly this. " \
47
+ "Anything else — a change, a question, a new request — is cancel_pending."
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -33,6 +33,9 @@ module Insika
33
33
  "the user you found nothing. Do not narrate the retries. Then conclude.\n" \
34
34
  "- Tool error: read the error, fix the arguments or try another path; never " \
35
35
  "repeat the exact same call.\n" \
36
+ "- Report an action as done only after its tool call returned success. Never " \
37
+ "say it happened before the call, and never for a call that failed, was " \
38
+ "blocked, or is still awaiting confirmation.\n" \
36
39
  "- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
37
40
  "construct, guess, or rewrite the domain, host, or path."
38
41
 
@@ -83,7 +86,9 @@ module Insika
83
86
  # NO identity at all, and a chatty model answered plausibly enough to hide
84
87
  # it. Additive to prompt_files, not exclusive: an agent may carry both.
85
88
  parts << profile&.base_prompt.to_s
86
- Array(profile&.prompt_files).each { |src| parts << read_source(profile&.id, src.to_s) }
89
+ sources = Array(profile&.prompt_files)
90
+ stored = stored_files(profile, sources)
91
+ sources.each { |src| parts << read_source(stored, src.to_s) }
87
92
  identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
88
93
  # Discipline rides an EXISTING identity, never substitutes one: an
89
94
  # empty identity here is fatal (`call` raises), not silently patched
@@ -109,10 +114,18 @@ module Insika
109
114
  @system_files.list.map { |name| @system_files.read(name).to_s }
110
115
  end
111
116
 
117
+ # The agent's files from ONE store read (`contents`); an agent_files object
118
+ # that only answers `read(agent_id, name)` is read per file, as before.
119
+ def stored_files(profile, sources)
120
+ return {} if sources.empty? || profile.id.nil? || @agent_files.nil?
121
+ return @agent_files.contents(profile.id) if @agent_files.respond_to?(:contents)
122
+
123
+ sources.to_h { |src| [src.to_s, @agent_files.read(profile.id, src.to_s)] }.compact
124
+ end
125
+
112
126
  # Per-agent store first, disk second (compat/seed).
113
- def read_source(agent_id, src)
114
- stored = agent_id && @agent_files&.read(agent_id, src)
115
- return stored if stored
127
+ def read_source(stored, src)
128
+ return stored[src] if stored[src]
116
129
  return File.read(src, encoding: "UTF-8") if File.exist?(src)
117
130
 
118
131
  ""
@@ -39,7 +39,7 @@ module Insika
39
39
  source: id
40
40
  )
41
41
  end
42
- compaction ? [compaction_fragment(compaction)] + fragments : fragments
42
+ compaction ? [compaction_fragment(compaction, request)] + fragments : fragments
43
43
  end
44
44
 
45
45
  private
@@ -71,7 +71,11 @@ module Insika
71
71
  # Symbol-keyed message preserving tool_calls / tool_call_id when present
72
72
  # (a plain message keeps just {role, content} — parity with the old shape).
73
73
  def normalize(msg)
74
+ return msg if msg["native_compaction"]
75
+
74
76
  h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
77
+ attachments = msg[:attachments] || msg["attachments"]
78
+ h[:attachments] = attachments if attachments && !attachments.empty?
75
79
  tool_calls = msg[:tool_calls] || msg["tool_calls"]
76
80
  tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
77
81
  h[:tool_calls] = tool_calls if tool_calls
@@ -114,12 +118,44 @@ module Insika
114
118
  fresh = fresh_session(session)
115
119
  return [[], nil] if fresh.nil?
116
120
 
117
- messages = fresh.messages || []
121
+ # Operator corrections affect replay only, never the stored content or tool protocol.
122
+ messages = (fresh.messages || []).map do |message|
123
+ if message["role"] == "assistant" && Array(message["tool_calls"]).empty? && message["context_content"].is_a?(String)
124
+ message.merge("content" => message["context_content"])
125
+ else
126
+ message
127
+ end
128
+ end
118
129
  state = fresh.respond_to?(:compaction) ? fresh.compaction : nil
119
130
  upto = state ? state["upto"].to_i : 0
120
- return [messages, nil] unless upto.positive? && Coercion.present?(state["summary"])
131
+ if upto.positive? && state["native"]
132
+ # ponytail: budget the full fallback too; model-aware allocation can
133
+ # recover this headroom once model selection precedes context building.
134
+ prefix = { "native_compaction" => state["native"], "messages" => without_no_reply(messages.take(upto)) }
135
+ return [[prefix] + without_no_reply(messages.drop(upto)), nil]
136
+ end
137
+ return [without_no_reply(messages), nil] unless upto.positive? && Coercion.present?(state["summary"])
138
+
139
+ [without_no_reply(messages.drop([upto, messages.size].min)), state]
140
+ end
121
141
 
122
- [messages.drop([upto, messages.size].min), state]
142
+ # `NO_REPLY` is the OpenClaw gateway's silence sentinel. Conversations migrated
143
+ # from it carry it as agent replies, and the compaction summary restates the
144
+ # rule ("call_support → responder NO_REPLY"); the Insika prompts forbid the
145
+ # word (silence is an empty answer here), so replaying it only teaches the
146
+ # model to write it (Loja Exemplo and Supermercado Exemplo, 23/10/2026).
147
+ # Replay-only, like the operator corrections above: the stored transcript is
148
+ # untouched. Applied after the compaction cut so `upto` keeps indexing the
149
+ # stored list. A message left empty is dropped — the customer never saw it.
150
+ def without_no_reply(messages)
151
+ messages.filter_map do |message|
152
+ content = message["content"]
153
+ next message unless message["role"] == "assistant" && Array(message["tool_calls"]).empty? &&
154
+ content.is_a?(String) && content.include?("NO_REPLY")
155
+
156
+ kept = content.lines.grep_v(/NO_REPLY/).join.strip
157
+ message.merge("content" => kept) unless kept.empty?
158
+ end
123
159
  end
124
160
 
125
161
  # The compacted prefix as ONE history fragment, rendered FIRST (the
@@ -127,10 +163,14 @@ module Insika
127
163
  # provider-agnostic (a mid-history "system" message is not). Priority
128
164
  # COMPACTION (59): the "oldest unit" — under budget it drops before any
129
165
  # verbatim message. source "compaction" -> its own context-trace category.
130
- def compaction_fragment(state)
166
+ # Fenced when the agent has `fencing` on: the summary is model-written from
167
+ # customer text, and the notice promises the model this block is material.
168
+ def compaction_fragment(state, request)
169
+ summary = state["summary"].to_s
170
+ summary = Insika::Fence.sanitize_text(summary) if Insika::Fence.enabled?(request.profile)
131
171
  ContextFragment.build(
132
172
  content: { role: "user",
133
- content: "<conversation_summary>\n#{state['summary']}\n</conversation_summary>" },
173
+ content: "<conversation_summary>\n#{summary}\n</conversation_summary>" },
134
174
  placement: :history,
135
175
  priority: Context::Priority::COMPACTION,
136
176
  source: "compaction"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ # Optional second pass over already scoped retrieval candidates.
6
+ class Reranker
7
+ MAX_INPUT_TOKENS = 4_000
8
+
9
+ def initialize(llm:)
10
+ @llm = llm
11
+ end
12
+
13
+ # Returns indexes into documents, or nil so the caller keeps lexical order.
14
+ def select(query:, documents:, config:, top_k:, emit: nil, source: "Knowledge", budget: MAX_INPUT_TOKENS)
15
+ return nil if documents.empty?
16
+
17
+ # The same estimator used by the context budget bounds the provider input.
18
+ tokens = [budget.to_i, MAX_INPUT_TOKENS].min
19
+ query_tokens = [tokens / 4, 1_000].min
20
+ per_document = [(tokens - query_tokens) / documents.length, 400].min
21
+ return nil if per_document < 1
22
+
23
+ bounded = documents.map { |text| trim(Insika::Safety::Detectors.redact(text).first, per_document) }
24
+ bounded_query = trim(Insika::Safety::Detectors.redact(query).first, query_tokens)
25
+ llm = operation_context(emit, config)
26
+ result = Async::Task.current.with_timeout(config.fetch("timeout_seconds")) do
27
+ llm.rerank(bounded_query, bounded, provider: config.fetch("provider"),
28
+ model: config.fetch("model"), top_n: top_k)
29
+ end
30
+ indexes = result.results.map(&:index)
31
+ unless indexes.length == [top_k, documents.length].min &&
32
+ indexes.all? { |index| index.is_a?(Integer) && index >= 0 && index < documents.length } &&
33
+ indexes.uniq.length == indexes.length
34
+ safe_emit(emit, :provider_warning, { provider: source, message: "invalid rerank indexes" })
35
+ return nil
36
+ end
37
+ safe_emit(emit, :retrieval_reranked,
38
+ { provider: source, candidate_count: documents.length, selected_count: indexes.length })
39
+ indexes
40
+ rescue StandardError => error
41
+ # Provider messages may quote the query or a document. Only the class is safe.
42
+ safe_emit(emit, :provider_warning, { provider: source, message: "rerank failed: #{error.class.name}" })
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def trim(value, limit)
49
+ text = value.to_s[0, limit * 4]
50
+ text = text[0...-1] while TokenEstimator.estimate(text) > limit
51
+ text
52
+ end
53
+
54
+ def safe_emit(emit, type, data)
55
+ emit&.call(type, data)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def operation_context(emit, config)
61
+ source = @llm.respond_to?(:call) ? @llm.call : @llm
62
+ return source unless emit && source.respond_to?(:config)
63
+
64
+ require "ruby_llm"
65
+ require_relative "../telemetry/ruby_llm_instrumenter"
66
+ copy = source.config.dup
67
+ copy.instrumenter = Insika::Telemetry::RubyLLMInstrumenter.new(
68
+ delegate: copy.instrumenter, operation: "rerank", model: config.fetch("model"),
69
+ emit: ->(type, data) { safe_emit(emit, type, data) }
70
+ )
71
+ RubyLLM::Context.new(copy)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -73,7 +73,7 @@ module Insika
73
73
  DROP_KEYS = %w[schema unknown_key oversized bad_turns duplicate capped].freeze
74
74
 
75
75
  # ask: ->(prompt) { "<raw model text>" } | something answering #content
76
- # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
76
+ # (+ RubyLLM-compatible #tokens for cost).
77
77
  # model: the ref recorded in the events ("utility_model" default).
78
78
  attr_reader :model
79
79
 
@@ -93,7 +93,7 @@ module Insika
93
93
  # Drops are counted, never fixed up.
94
94
  def distill(prompt:, message_count:, max_proposals: 10)
95
95
  answer = @ask.call(prompt)
96
- raw = parse(text_of(answer))
96
+ raw = parse(answer)
97
97
  proposals = []
98
98
  dropped = DROP_KEYS.to_h { |k| [k, 0] }
99
99
  seen = {}
@@ -124,20 +124,25 @@ module Insika
124
124
  # nil when the provider said nothing — never 0 (the Proposer's
125
125
  # discipline). The cached prefix is INCLUDED in the spent total.
126
126
  def cost_of(answer)
127
- return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
127
+ return nil unless answer.respond_to?(:tokens)
128
128
 
129
- input = answer.input_tokens.to_i
130
- output = answer.output_tokens.to_i
131
- cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
132
- spent = input + output + cached
129
+ usage = answer.tokens
130
+ input = usage.input.to_i
131
+ output = usage.output.to_i
132
+ cached = usage.cache_read.to_i
133
+ spent = input + output + cached + usage.cache_write.to_i
133
134
  spent.positive? ? { "spent" => spent, "cached" => cached } : nil
134
135
  end
135
136
 
136
- # Fences are stripped, the payload parsed STRICTLY: a model that
137
- # improvises a schema fails here instead of producing half a proposal.
138
- def parse(raw)
139
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
140
- parsed = JSON.parse(body)
137
+ # Native responses own JSON parsing; injected text asks retain their
138
+ # existing fenced-JSON contract. Domain validation still runs below.
139
+ def parse(answer)
140
+ parsed = if answer.respond_to?(:parsed)
141
+ answer.parsed
142
+ else
143
+ JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
144
+ end
145
+ parsed = parsed["items"] if parsed.is_a?(Hash)
141
146
  raise Unusable, "the distiller's answer is not an array" unless parsed.is_a?(Array)
142
147
 
143
148
  parsed
@@ -215,8 +220,12 @@ module Insika
215
220
  require "ruby_llm"
216
221
  llm ||= RubyLLM
217
222
  lambda do |prompt|
223
+ # DeepSeek's native adapter uses JSON-object mode, requiring an envelope.
224
+ schema = { "type" => "object", "properties" => { "items" => PROPOSAL_SCHEMA.json_schema },
225
+ "required" => ["items"], "additionalProperties" => false }
218
226
  llm.chat(model: model, provider: provider, assume_model_exists: true)
219
- .with_temperature(0).ask(prompt)
227
+ .with_temperature(0).with_schema("schema" => schema, "strict" => false)
228
+ .ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
220
229
  end
221
230
  end
222
231
  end
data/lib/insika/doctor.rb CHANGED
@@ -180,7 +180,8 @@ module Insika
180
180
  check_soak_envelope check_turn_timing check_grounding check_cache_layers
181
181
  check_memory_scopes check_funnel_declarations check_followup check_distill
182
182
  check_compaction check_harvest check_schedules check_guardrail_corpora
183
- check_tool_allowlist_policy]
183
+ check_tool_allowlist_policy check_fencing check_eval_seeding
184
+ check_presentation_tools check_provenance check_customer_confirm]
184
185
 
185
186
  def safe(check)
186
187
  Array(send(check))
@@ -315,16 +316,18 @@ module Insika
315
316
  [Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
316
317
  end
317
318
 
319
+ def backend_name = @backend.class.name.split("::").last
320
+
318
321
  def check_db
319
- unless @backend.is_a?(Insika::Stores::SQLite)
322
+ unless Insika::Stores.durable?(@backend)
320
323
  return [Finding.new(check: "db", severity: :info,
321
324
  message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
322
325
  end
323
326
 
324
327
  @backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
325
- [ok("db", "durable backend: SQLite (readable)")]
328
+ [ok("db", "durable backend: #{backend_name} (readable)")]
326
329
  rescue StandardError => e
327
- [Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
330
+ [Finding.new(check: "db", severity: :error, message: "#{backend_name} backend not usable: #{e.class}: #{e.message}", fix: nil)]
328
331
  end
329
332
 
330
333
  def check_llm_provider
@@ -836,6 +839,7 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
836
839
  # identity block bills a cache write every turn).
837
840
  IDENTITY_BUILTINS = %w[
838
841
  Insika::Context::Providers::Prompt
842
+ Insika::Context::Providers::FenceNotice
839
843
  Insika::Context::Providers::Skill
840
844
  Insika::Context::Providers::ToolSearch
841
845
  ].freeze
@@ -942,6 +946,96 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
942
946
 
943
947
  def names_tool_allowlist?(raw) = Array(raw["policies"]).any? { |p| p.to_s == "tool_allowlist" }
944
948
 
949
+ # A stored agent that allows a PRESENTATION tool but no tool declaring
950
+ # `evidence`. The presentation tool shows only cards an evidence tool returned
951
+ # this session, so with no evidence tool in reach every call drops every id —
952
+ # the model keeps asking to show products and nothing ever appears. Silent
953
+ # unless a presentation tool exists. Data tools only: a code tool's evidence
954
+ # lives in registry metadata the doctor does not read.
955
+ def check_presentation_tools
956
+ return [] unless @tool_store && @profile_source
957
+
958
+ raws = @tool_store.all_raw
959
+ presenting = raws.select { |r| r["presentation"] }.map { |r| r["name"].to_s }
960
+ return [] if presenting.empty?
961
+
962
+ evidence = raws.select { |r| r["evidence"] }.map { |r| r["name"].to_s }
963
+ bad = @profile_source.all.filter_map do |profile|
964
+ allowed = allowed_data_tools(profile, raws)
965
+ shows = presenting & allowed
966
+ next if shows.empty? || !(evidence & allowed).empty?
967
+
968
+ Finding.new(check: "presentation-tools", severity: :warn, fix: nil,
969
+ message: "agent '#{profile.id}' allows #{shows.join(', ')} but no tool declaring " \
970
+ "evidence — a presentation tool can only show cards an evidence tool " \
971
+ "returned this session, so every call would drop every id. Allow the " \
972
+ "search tool that declares `evidence`, or drop the presentation tool.")
973
+ end
974
+ return bad unless bad.empty?
975
+
976
+ [ok("presentation-tools", "#{presenting.length} presentation tool(s): every agent that shows cards " \
977
+ "also allows an evidence tool")]
978
+ end
979
+
980
+ # The data tools a profile may call, by the allowlist's own rules: nil lists =
981
+ # all; otherwise names ∪ groups; deny wins.
982
+ def allowed_data_tools(profile, raws)
983
+ names = profile.tools_allow.nil? ? nil : Array(profile.tools_allow).map(&:to_s)
984
+ groups = profile.tools_allow_groups.nil? ? nil : Array(profile.tools_allow_groups).map(&:to_s)
985
+ allowed = if names.nil? && groups.nil?
986
+ raws.map { |r| r["name"].to_s }
987
+ else
988
+ Array(names) | raws.select { |r| Array(groups).include?(r["group"].to_s) }.map { |r| r["name"].to_s }
989
+ end
990
+ allowed - Array(profile.tools_deny).map(&:to_s)
991
+ end
992
+
993
+ def check_provenance
994
+ return [] unless @tool_store && @profile_source.respond_to?(:all_raw)
995
+
996
+ tools = @tool_store.all_raw
997
+ @profile_source.all_raw.filter_map do |raw|
998
+ allowed = tools.select do |tool|
999
+ name = tool["name"]
1000
+ unrestricted = raw["tools_allow"].nil? && raw["tools_allow_groups"].nil?
1001
+ (unrestricted || Array(raw["tools_allow"]).include?(name) ||
1002
+ Array(raw["tools_allow_groups"]).include?(tool["group"])) &&
1003
+ !Array(raw["tools_deny"]).include?(name)
1004
+ end
1005
+ gated = allowed.select { |tool| tool["requires_evidence"] }
1006
+ next if gated.empty? || allowed.any? { |tool| tool["evidence"] }
1007
+
1008
+ Finding.new(check: "provenance", severity: :warn, fix: nil,
1009
+ message: "agent '#{raw["id"]}' allows #{gated.map { |t| t["name"] }.join(', ')} " \
1010
+ "with requires_evidence but no stored evidence tool — verify that an allowed code tool supplies evidence")
1011
+ end
1012
+ end
1013
+
1014
+ # `customer_confirm` names tools; one the agent denies, or leaves out of an
1015
+ # explicit allowlist, is a hold that can never happen. An allowlist carrying an
1016
+ # `mcp:` entry is not explicit about tool NAMES (the server contributes them),
1017
+ # so it is not judged.
1018
+ def check_customer_confirm
1019
+ return [] unless @profile_source
1020
+
1021
+ findings = @profile_source.all.flat_map do |profile|
1022
+ names = profile.respond_to?(:customer_confirm) ? Array(profile.customer_confirm) : []
1023
+ next [] if names.empty?
1024
+
1025
+ allow = profile.tools_allow
1026
+ explicit = !allow.nil? && Array(allow).none? { |a| a.to_s.start_with?("mcp:") }
1027
+ dead = names.select do |name|
1028
+ Array(profile.tools_deny).include?(name) || (explicit && !Array(allow).include?(name))
1029
+ end
1030
+ next [] if dead.empty?
1031
+
1032
+ [Finding.new(check: "customer_confirm", severity: :warn, fix: nil,
1033
+ message: "agent '#{profile.id}' lists #{dead.join(', ')} under customer_confirm but does " \
1034
+ "not allow the tool — the hold can never happen")]
1035
+ end
1036
+ findings.empty? ? [ok("customer_confirm", "customer_confirm: every held tool is allowed")] : findings
1037
+ end
1038
+
945
1039
  def check_grounding
946
1040
  return [] unless @profile_source
947
1041
 
@@ -1314,6 +1408,9 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1314
1408
  settings = @settings_store.get
1315
1409
  config = settings["compaction"] || {}
1316
1410
  return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
1411
+ if config["mode"] == "native"
1412
+ return [ok("compaction", "native compaction on for supported Responses models; no summary fallback")]
1413
+ end
1317
1414
 
1318
1415
  if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
1319
1416
  [Finding.new(check: "compaction", severity: :warn, fix: nil,
@@ -1325,6 +1422,47 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1325
1422
  end
1326
1423
  end
1327
1424
 
1425
+ # `evals.seeding` opens POST /v1/conversations/:id/seed: a snapshot written
1426
+ # into a real conversation under the tenant token, before its first turn. Right
1427
+ # on the machine running snapshot evals; wrong left on in production, where any
1428
+ # consumer holding the token could write a customer's history. A warning, not
1429
+ # an error — the operator may be running the evals right now.
1430
+ def check_eval_seeding
1431
+ return [] unless @settings_store
1432
+
1433
+ on = ((@settings_store.get["evals"] || {})["seeding"]) == true
1434
+ return [ok("eval-seeding", "evals.seeding off — conversations cannot be seeded")] unless on
1435
+
1436
+ [Finding.new(check: "eval-seeding", severity: :warn, fix: nil,
1437
+ message: "evals.seeding is ON — POST /v1/conversations/:id/seed accepts a fabricated " \
1438
+ "conversation state under the tenant token. Fine while running snapshot " \
1439
+ "evals; turn it off (Studio > Settings, or `update_settings`) in production.")]
1440
+ end
1441
+
1442
+ # A customer-facing agent — one reachable through an inbound channel: any
1443
+ # agent when the relay is mounted (the event names the agent), the listed
1444
+ # ones for the widget — with `fencing` off reads third-party bytes as-is.
1445
+ # A warning, never an error: the default is off this release on purpose
1446
+ # (the goldens were baselined unfenced), so the message says exactly what
1447
+ # is unsanitized today. No inbound channel = nothing customer-facing = silent.
1448
+ def check_fencing
1449
+ return [] unless @profile_source
1450
+
1451
+ relay = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
1452
+ widget = @env["INSIKA_WIDGET_AGENTS"].to_s.split(",").map(&:strip).reject(&:empty?)
1453
+ return [] unless relay || widget.any?
1454
+
1455
+ exposed = @profile_source.all.select { |p| relay || widget.include?(p.id.to_s) }
1456
+ unfenced = exposed.reject { |p| Insika::Fence.enabled?(p) }.map(&:id)
1457
+ return [ok("fencing", "fencing on for every agent behind an inbound channel")] if unfenced.empty?
1458
+
1459
+ [Finding.new(check: "fencing", severity: :warn, fix: nil,
1460
+ message: "agent(s) #{unfenced.join(', ')}: reachable through an inbound channel with " \
1461
+ "`fencing` off — tool results, <memory> facts and <knowledge> concepts reach " \
1462
+ "the model byte-for-byte (invisible characters, forged turn markers and " \
1463
+ "transcript-shaped tags included). Set `fencing true` on the agent.")]
1464
+ end
1465
+
1328
1466
  # the harvest check — per profile WITH a harvest hash:
1329
1467
  # declared-without-model warn (D12), no grounding matcher warn (D3),
1330
1468
  # malformed negative list error (D4), else ok with the pending counts.
@@ -114,7 +114,8 @@ module Insika
114
114
  edge_limiter: c[:edge_limiter],
115
115
  executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
116
116
  context_trace_store: c[:context_trace_store],
117
- cache_series_store: c[:cache_series_store], llm: @llm }
117
+ cache_series_store: c[:cache_series_store], llm: @llm,
118
+ llm_refresh: -> { c[:configurator].refresh } }
118
119
  )
119
120
  register_authoring_commands(graph, c)
120
121
  register_workflows(graph)
@@ -153,7 +154,8 @@ module Insika
153
154
  end
154
155
 
155
156
  graph.bus.register(:trigger_workflow, Insika::Commands::TriggerWorkflow.new(
156
- profiles: graph.profiles, session_store: graph.session_store,
157
+ profiles: Insika::AgentDefaults.for_turns(graph.profiles, @components[:settings_store]),
158
+ session_store: graph.session_store,
157
159
  task_store: graph.task_store, executor: graph.executor,
158
160
  workflow_registry: graph.workflow_registry
159
161
  ))
@@ -180,9 +182,35 @@ module Insika
180
182
  attrs[:headers] = existing["headers"]
181
183
  end
182
184
  mcp_store.upsert(attrs)
185
+ list_tools_once(c, attrs[:name])
183
186
  end
184
187
  end
185
188
 
189
+ # A CODE-DECLARED server whose tools were never listed offers the agent
190
+ # NOTHING: the registry's `entries` reads the cached descriptors, so an empty
191
+ # cache means the model is never told those tools exist and answers out of its
192
+ # own head instead — silently, which is the worst shape a missing tool can
193
+ # take (a bench run caught exactly that: an agent with no catalogue invented
194
+ # one). So the FIRST boot lists them.
195
+ #
196
+ # A failure to list does NOT stop the boot — a stdio server behind a closed
197
+ # gate and a server that is momentarily down are both ordinary, and a
198
+ # deployment that refuses to start over either would be worse than the problem.
199
+ # It is said out loud instead, naming the consequence, because the failure this
200
+ # exists to end is the silent one.
201
+ #
202
+ # Only when the cache is empty. A later boot is free, and an operator who
203
+ # refreshed by hand is not overruled.
204
+ def list_tools_once(c, name)
205
+ record = c[:mcp_store].get_raw(name.to_s)
206
+ return unless record && record["enabled"] && Array(record["tools_cache"]).empty?
207
+
208
+ c[:mcp_tool_registry].refresh(name.to_s)
209
+ rescue StandardError => e
210
+ warn "insika: mcp \"#{name}\" was declared in code but its tools could not be listed " \
211
+ "(#{e.message}). The agent runs WITHOUT them until `insika mcp refresh #{name}` succeeds."
212
+ end
213
+
186
214
  def build_components(backend, spine)
187
215
  config_store = Insika::ConfigStore.new(store: backend)
188
216
  settings_store = Insika::SettingsStore.new(config_store: config_store)
@@ -231,14 +259,16 @@ module Insika
231
259
  base: "", catalog: c[:prompt_catalog],
232
260
  agent_files: c[:agent_file_store], system_files: c[:system_file_store]
233
261
  ),
262
+ Insika::Context::Providers::FenceNotice.new,
234
263
  Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
235
264
  Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
236
265
  Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
237
- Insika::Context::Providers::Memory.new(store: spine.memory_store),
238
- Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
266
+ Insika::Context::Providers::Memory.new(store: spine.memory_store, llm: @llm),
267
+ Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store, llm: @llm),
239
268
  # Session briefing: read path. Inert for agents without briefing_fields.
240
269
  # Before Session, so the durable head renders above the transcript and
241
270
  # the tail recitation lands after it.
271
+ Insika::Context::Providers::PendingConfirmation.new(pending_action_store: spine.pending_action_store),
242
272
  Insika::Context::Providers::Briefing.new(session_store: spine.session_store),
243
273
  Insika::Context::Providers::Session.new(session_store: spine.session_store)
244
274
  ] # NOT frozen: load_plugins appends plugin providers at boot
@@ -267,6 +297,8 @@ module Insika
267
297
  # the LGPD access right — the Studio Customers drill exports.
268
298
  bus.register(:export_customer_memory, Insika::Commands::ExportCustomerMemory.new(memory_store: mem, event_stream: es))
269
299
  bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
300
+ bus.register(:write_kit, Insika::Commands::WriteKit.new(settings_store: c[:settings_store], event_stream: es))
301
+ bus.register(:delete_kit, Insika::Commands::DeleteKit.new(settings_store: c[:settings_store], event_stream: es))
270
302
  bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
271
303
  bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
272
304
  bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
@@ -309,6 +341,7 @@ module Insika
309
341
  return nil unless RubyLLM.respond_to?(:context)
310
342
 
311
343
  primary = provider_name
344
+ # Preserve the host instrumenter; Executor wraps a copy for each task chat.
312
345
  RubyLLM.context do |cfg|
313
346
  provider_names.each do |provider|
314
347
  # The explicit api_key/api_base belong to the PRIMARY provider; the
@@ -95,6 +95,8 @@ module Insika
95
95
  session_store: @graph.session_store,
96
96
  settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
97
97
  mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
98
+ llm_trace_store: @graph.llm_trace_store,
99
+ model_metrics_store: @graph.model_metrics_store,
98
100
  tool_trace_store: @rt.component(:tool_trace_store),
99
101
  context_trace_store: @rt.component(:context_trace_store),
100
102
  # the per-agent cache-hit series (agent-detail card).
@@ -134,20 +136,10 @@ module Insika
134
136
  # config (nil without INSIKA_ARTIFACT_SIGNING_KEY) decides whether a
135
137
  # signed sharing link exists at all.
136
138
  artifact_store: @graph.artifact_store,
137
- artifact_signing: artifact_signing
139
+ artifact_signing: Insika::ArtifactSigning.studio_config
138
140
  )
139
141
  end
140
142
 
141
- # -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
142
- def artifact_signing
143
- key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
144
- return nil if key.to_s.empty?
145
-
146
- { key: key,
147
- ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
148
- base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
149
- end
150
-
151
143
  # The frozen criterion — best-effort at boot: a bare install
152
144
  # without the env var renders the ruler's hole instead of
153
145
  # crashing the Studio.
@@ -166,7 +158,7 @@ module Insika
166
158
  end
167
159
 
168
160
  def persistence
169
- @graph.durable? ? "durable (sqlite)" : "ephemeral (memory)"
161
+ @graph.durable? ? "durable (#{@graph.backend.class.name.split("::").last.downcase})" : "ephemeral (memory)"
170
162
  end
171
163
 
172
164
  def banner(telemetry = nil)