insika 0.3.0 → 0.8.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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +99 -17
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +50 -19
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. data/docs/understand.md +0 -10
data/lib/insika/doctor.rb CHANGED
@@ -104,7 +104,8 @@ module Insika
104
104
  shadow_pair_store: nil, soak_envelope_path: nil, context_providers: nil,
105
105
  memory_store: nil, agent_ids: nil, funnel_store: nil, outcome_store: nil,
106
106
  followup_store: nil, contact_store: nil, proposal_store: nil,
107
- harvest_store: nil, harvest_criterion: nil)
107
+ schedule_store: nil,
108
+ harvest_store: nil, harvest_criterion: nil, mcp_store: nil)
108
109
  @env = env
109
110
  @settings_store = settings_store
110
111
  @llm_provider_store = llm_provider_store
@@ -136,10 +137,16 @@ module Insika
136
137
  # the distillation check — nil collaborator = the check
137
138
  # reports declarations only (counts skipped).
138
139
  @proposal_store = proposal_store
140
+ # the schedules check — nil collaborator = the check
141
+ # reports declarations only (skip counts skipped).
142
+ @schedule_store = schedule_store
139
143
  # the harvest check — nil collaborators = the check
140
144
  # reports declarations only (counts skipped).
141
145
  @harvest_store = harvest_store
142
146
  @harvest_criterion = harvest_criterion
147
+ # the mcp check — nil collaborator = the check reports nothing
148
+ # (env-only callers stay cheap).
149
+ @mcp_store = mcp_store
143
150
  end
144
151
 
145
152
  # -> Report. Never raises (a broken check degrades to an :error Finding).
@@ -168,11 +175,12 @@ module Insika
168
175
  private
169
176
 
170
177
  def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
171
- check_admin_token check_data_tools check_prompt_files check_relay_channel
172
- check_web_widget check_skill_eager check_skill_drift check_shadow_parity
178
+ check_admin_token check_web_concurrency check_data_tools check_mcp check_prompt_files
179
+ check_relay_channel check_web_widget check_skill_eager check_skill_drift check_shadow_parity
173
180
  check_soak_envelope check_turn_timing check_grounding check_cache_layers
174
181
  check_memory_scopes check_funnel_declarations check_followup check_distill
175
- check_harvest check_guardrail_corpora]
182
+ check_compaction check_harvest check_schedules check_guardrail_corpora
183
+ check_tool_allowlist_policy]
176
184
 
177
185
  def safe(check)
178
186
  Array(send(check))
@@ -336,6 +344,54 @@ module Insika
336
344
  message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
337
345
  end
338
346
 
347
+ # WEB_CONCURRENCY>1 is a legitimate config (docs/DEPLOY.md "The process
348
+ # model") — but it silently drops per-worker session guarantees
349
+ # (FIFO/collect/steer) unless the operator has sticky routing per session in
350
+ # front, and nothing else in the deployment surfaces that drift. This is
351
+ # exactly how a staging service ended up at N=4 unnoticed and leaked a reply
352
+ # across sessions. The engine cannot see whether sticky routing exists off
353
+ # Railway, so that case only warns — N>1 stays a valid choice for an
354
+ # operator who put a real sticky proxy in front. On Railway it is not a
355
+ # valid choice TODAY: Railway's own docs say it "does not support sticky
356
+ # sessions" and randomly distributes traffic — there is no way to satisfy
357
+ # the precondition on this platform, so this errors instead of warning.
358
+ # RAILWAY_ENVIRONMENT_NAME is injected into every Railway deployment
359
+ # (docs.railway.com/variables/reference), so its presence is a reliable
360
+ # "are we on Railway" signal.
361
+ def check_web_concurrency
362
+ n = @env["WEB_CONCURRENCY"].to_i
363
+ return [ok("web-concurrency", "WEB_CONCURRENCY=1 (default) — session semantics hold cluster-wide")] if n <= 1
364
+
365
+ if router_detected?
366
+ return [ok("web-concurrency", "WEB_CONCURRENCY=#{n} behind a detected insika-router " \
367
+ "sticky routing satisfies the precondition")]
368
+ end
369
+
370
+ if Insika::Coercion.present?(@env["RAILWAY_ENVIRONMENT_NAME"])
371
+ return [Finding.new(check: "web-concurrency", severity: :error,
372
+ message: "WEB_CONCURRENCY=#{n} on Railway — Railway does not support sticky " \
373
+ "sessions (docs.railway.com/deployments/scaling), so per-session " \
374
+ "FIFO/collect/steer WILL break across workers with no way to fix it " \
375
+ "at the routing layer; set WEB_CONCURRENCY=1, or run insika-router " \
376
+ "in front of N local workers instead", fix: nil)]
377
+ end
378
+
379
+ [Finding.new(check: "web-concurrency", severity: :warn,
380
+ message: "WEB_CONCURRENCY=#{n} — per-session FIFO/collect/steer only hold on the worker " \
381
+ "that owns the session; make sure sticky routing per session sits in front (e.g. " \
382
+ "insika-router, set INSIKA_ROUTER_BACKENDS/_DNS), or accept " \
383
+ "per-worker best-effort (docs/DEPLOY.md \"The process model\")", fix: nil)]
384
+ end
385
+
386
+ # A router is "detected", not verified — the doctor cannot see whether a
387
+ # process at these addresses actually runs `insika-router`, only that the
388
+ # operator configured one. Same precedent as every other
389
+ # env-based capability check in this file.
390
+ def router_detected?
391
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS"]) ||
392
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS_DNS"])
393
+ end
394
+
339
395
  # the soak envelope (a deployment-side file). Absent is :info — not every
340
396
  # deployment soaks, and the tooling is optional. Present-and-broken is
341
397
  # :error — an envelope that exists but does not parse is a pre-declaration
@@ -667,26 +723,59 @@ module Insika
667
723
  # the mangled prompt on every turn, and nothing else would ever say so: the file is
668
724
  # present, non-empty, and the agent answers — worse than a crash. Found on the pilot
669
725
  # by an `insika refine` report, three weeks after the fact.
726
+ #
727
+ # The same sweep also WARNS (never errors) on a file that outgrew a prompt.
728
+ # Merchant packs are LLM-generated (generate-merchant-pack), and generated prose
729
+ # bloats: the pilot's 28 KB AGENTS.md is the local example, and the ETH Zurich
730
+ # instruction-file study puts the cost of that shape at 20%+ extra tokens per
731
+ # turn for no extra instruction-following. The thresholds are deliberately
732
+ # generous — a hand-written file never meets them; only the generated shape does.
670
733
  def check_prompt_files
671
734
  return [] unless @agent_file_store
672
735
 
673
736
  agents = @agent_file_store.agents
674
- wrapped = agents.flat_map do |agent|
675
- @agent_file_store.list(agent).filter_map do |name|
676
- next unless wrapped_content?(@agent_file_store.read(agent, name))
677
-
678
- Finding.new(check: "prompt-files", severity: :error, fix: nil,
679
- message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
680
- "the model receives `{\"content\" => …}` on one line, escapes and all. " \
681
- "Recover the markdown from inside the wrapper and write it back.")
737
+ wrapped = []
738
+ oversized = []
739
+ agents.each do |agent|
740
+ @agent_file_store.list(agent).each do |name|
741
+ content = @agent_file_store.read(agent, name)
742
+ if wrapped_content?(content)
743
+ wrapped << Finding.new(check: "prompt-files", severity: :error, fix: nil,
744
+ message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
745
+ "the model receives `{\"content\" => …}` on one line, escapes and all. " \
746
+ "Recover the markdown from inside the wrapper and write it back.")
747
+ elsif (finding = oversized_prompt(agent, name, content))
748
+ oversized << finding
749
+ end
682
750
  end
683
751
  end
684
- return wrapped if wrapped.any?
752
+ return wrapped + oversized if wrapped.any? || oversized.any?
685
753
 
686
754
  total = agents.sum { |a| @agent_file_store.list(a).length }
687
755
  [ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
688
756
  end
689
757
 
758
+ # WARN thresholds for one prompt file. ~6 000 estimated tokens (~24 KB) or 600
759
+ # lines: the pilot's generated 28 KB / 292-line AGENTS.md trips the token bar,
760
+ # every hand-written demo file stays far under both. Estimate = chars/4, the
761
+ # same yardstick as Insika::TokenEstimator — cheap and honest about being ±15%.
762
+ PROMPT_FILE_WARN_TOKENS = 6_000
763
+ PROMPT_FILE_WARN_LINES = 600
764
+
765
+ def oversized_prompt(agent, name, content)
766
+ text = content.to_s
767
+ tokens = Insika::TokenEstimator.estimate(text)
768
+ lines = text.lines.count
769
+ return nil if tokens <= PROMPT_FILE_WARN_TOKENS && lines <= PROMPT_FILE_WARN_LINES
770
+
771
+ Finding.new(check: "prompt-files", severity: :warn, fix: nil,
772
+ message: "agent '#{agent}' file '#{name}' is ~#{tokens} tokens over #{lines} line(s) " \
773
+ "(threshold: #{PROMPT_FILE_WARN_TOKENS} tokens / #{PROMPT_FILE_WARN_LINES} lines) — " \
774
+ "a prompt this large costs 20%+ more tokens on every turn for no better " \
775
+ "instruction-following. Trim it, or split the reference material into skills " \
776
+ "the agent loads on demand.")
777
+ end
778
+
690
779
  # Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
691
780
  # prompt does not open with `{"…" =>`.
692
781
  def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
@@ -694,11 +783,50 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
694
783
  def check_data_tools
695
784
  return [] unless @tool_store
696
785
 
697
- broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
698
- total = @tool_store.names.length
699
- return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
786
+ raws = @tool_store.all_raw
787
+ broken = raws.filter_map { |raw| broken_tool(raw) }
788
+ legacy = raws.select { |raw| raw["group"].to_s.start_with?("mcp:") }.map { |raw| legacy_mcp_snapshot(raw) }
789
+ base = broken.empty? ? [ok("data-tools", "#{raws.length} data tool(s): every definition valid")] : broken
790
+ base + legacy
791
+ end
792
+
793
+ # A data-tool in an `mcp:*` group was ingested by the
794
+ # RETIRED snapshot path (McpToolIngestor) — the live registry now serves
795
+ # that same MCP instance's tools directly. Still executes fine (never
796
+ # removed automatically), just a duplicate worth cleaning up.
797
+ def legacy_mcp_snapshot(raw)
798
+ Finding.new(check: "data-tools", severity: :info, fix: nil,
799
+ message: "data tool '#{raw["name"]}' (group #{raw["group"]}) is a legacy MCP " \
800
+ "snapshot — that instance's tools now run LIVE via the MCP registry; " \
801
+ "this frozen copy still works but duplicates it, safe to remove.")
802
+ end
803
+
804
+ # An http/sse instance whose credentials still sit under `env`
805
+ # (the pre-RFC meaning) is READ as `headers` (McpStore#raw), but the
806
+ # record on disk is unchanged until the operator re-saves it — flag it so
807
+ # it doesn't linger silently. A stdio instance that is enabled but
808
+ # INSIKA_MCP_STDIO is not set will save fine but refuse to start.
809
+ def check_mcp
810
+ return [] unless @mcp_store
811
+
812
+ raws = @mcp_store.all_raw
813
+ gated = raws.select do |r|
814
+ r["transport"].to_s == "stdio" && r["enabled"] &&
815
+ !Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO", @env))
816
+ end
817
+ findings = @mcp_store.legacy_header_names.map do |name|
818
+ Finding.new(check: "mcp", severity: :warn, fix: nil,
819
+ message: "MCP instance '#{name}' still stores credentials under 'env' — " \
820
+ "re-save it so they move to 'headers' (env is now stdio-only).")
821
+ end
822
+ findings += gated.map do |r|
823
+ Finding.new(check: "mcp", severity: :info, fix: nil,
824
+ message: "MCP instance '#{r["name"]}' is stdio and enabled, but INSIKA_MCP_STDIO " \
825
+ "is not set — it will refuse to start.")
826
+ end
827
+ return [ok("mcp", "#{raws.length} MCP instance(s): no legacy env-as-headers records")] if findings.empty?
700
828
 
701
- broken
829
+ findings
702
830
  end
703
831
 
704
832
  # the doctor cannot run a turn, so it cannot prove purity — it
@@ -774,6 +902,46 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
774
902
  # harmless but useless — every claim passes and the audit reads zero. A
775
903
  # warning, never an error: the pack owns matcher quality; the engine refuses
776
904
  # only uncompileable data.
905
+ # A stored agent that declares `tools_allow` / `tools_deny` /
906
+ # `tools_allow_groups` but does not name the `tool_allowlist` policy. Only
907
+ # that policy applies the lists, and the Policy::Engine runs ONLY the
908
+ # policies a profile names — so as written on disk the lists are inert and
909
+ # the model receives every registered tool (hundreds of schemas, tens of
910
+ # thousands of tokens, per request).
911
+ #
912
+ # AgentProfile.build now adds the policy whenever a list is declared, so a
913
+ # live turn off this record is already safe. The record itself stays wrong
914
+ # until something re-saves it, and anything that reads the stored shape
915
+ # directly — an older engine, a pack export, an operator auditing the
916
+ # Studio form — still sees an allowlist that does nothing. That is why this
917
+ # reads the RAW record and not `all`.
918
+ def check_tool_allowlist_policy
919
+ return [] unless @profile_source.respond_to?(:all_raw)
920
+
921
+ records = @profile_source.all_raw
922
+ bad = records.select { |r| tool_lists_declared?(r) && !names_tool_allowlist?(r) }
923
+ return [ok("tool-allowlist", "#{records.length} stored agent(s): every declared tool list names the policy")] if bad.empty?
924
+
925
+ bad.map do |r|
926
+ Finding.new(check: "tool-allowlist", severity: :error, fix: nil,
927
+ message: "agent '#{r["id"]}' declares a tool allow/deny list but its policies " \
928
+ "(#{Array(r["policies"]).join(", ").then { |p| p.empty? ? "none" : p }}) " \
929
+ "do not include tool_allowlist — as stored, the list is never applied and " \
930
+ "the model receives every registered tool. The engine repairs this when it " \
931
+ "loads the agent; re-save it (Studio, or the pack) so the stored policies " \
932
+ "match the intent.")
933
+ end
934
+ end
935
+
936
+ # Presence, not emptiness, for the two nil-able lists: `tools_allow: []`
937
+ # means "no tools" and is as much a declaration as a list of names.
938
+ # `tools_deny` has no nil state, so only a non-empty one counts.
939
+ def tool_lists_declared?(raw)
940
+ !raw["tools_allow"].nil? || !raw["tools_allow_groups"].nil? || !Array(raw["tools_deny"]).empty?
941
+ end
942
+
943
+ def names_tool_allowlist?(raw) = Array(raw["policies"]).any? { |p| p.to_s == "tool_allowlist" }
944
+
777
945
  def check_grounding
778
946
  return [] unless @profile_source
779
947
 
@@ -976,6 +1144,80 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
976
1144
  e.message
977
1145
  end
978
1146
 
1147
+ # the recurring-schedule check — declarations
1148
+ # validated where they are declared (each one through the SAME
1149
+ # Insika::Schedule parser the engine fires on, never a second opinion),
1150
+ # plus the data read that answers "what is being skipped right now".
1151
+ # A bare install (no schedule on any profile) reports NOTHING.
1152
+ def check_schedules
1153
+ return [] unless @profile_source
1154
+
1155
+ declared = @profile_source.all.select do |profile|
1156
+ profile.respond_to?(:schedules) && Array(profile.schedules).any?
1157
+ end
1158
+ return [] if declared.empty?
1159
+
1160
+ declared.flat_map do |profile|
1161
+ Array(profile.schedules).flat_map do |schedule|
1162
+ decl = Insika::Schedule.parse(schedule)
1163
+ if decl.nil?
1164
+ [Finding.new(check: "schedules", severity: :error, fix: nil,
1165
+ message: "agent '#{profile.id}': malformed schedule — " \
1166
+ "#{schedule_defect(schedule)}. The engine will never " \
1167
+ "fire it until this is fixed.")]
1168
+ else
1169
+ ok_finding = schedule_ok(profile, decl)
1170
+ [ok_finding] + schedule_data_findings(profile, decl)
1171
+ end
1172
+ end
1173
+ end
1174
+ end
1175
+
1176
+ # The ok finding for one valid declaration — plus the warning that
1177
+ # names the cadence ceiling: the engine claims ONE window per pass, so an
1178
+ # `every` shorter than the claim window cannot fire more often than it.
1179
+ def schedule_ok(profile, decl)
1180
+ trigger = decl.cron ? "cron #{decl.cron} <#{decl.tz}>" : "every #{decl.every}s"
1181
+ floor = Insika::ScheduleEngine::DEFAULT_WINDOW
1182
+ if !decl.cron.nil? || decl.every >= floor
1183
+ ok("schedules",
1184
+ "agent '#{profile.id}': schedule '#{decl.id}' — #{trigger}, " \
1185
+ "#{decl.fixed_session? ? "a fixed standing session" : 'a new session per run'}, " \
1186
+ "#{decl.enabled ? 'enabled' : 'disabled'}")
1187
+ else
1188
+ Finding.new(check: "schedules", severity: :warn, fix: nil,
1189
+ message: "agent '#{profile.id}': schedule '#{decl.id}' declares " \
1190
+ "every #{decl.every}s, but the engine fires at most once per " \
1191
+ "#{floor}s claim window — the effective cadence is one run " \
1192
+ "per ~#{floor}s, not one per #{decl.every}s.")
1193
+ end
1194
+ end
1195
+
1196
+ # The named defect, for the error message — the engine's parser's words.
1197
+ def schedule_defect(hash)
1198
+ Insika::Schedule.parse!(hash).to_s
1199
+ rescue Insika::ValidationError => e
1200
+ e.message
1201
+ end
1202
+
1203
+ # Data-age findings, gated on the optional collaborator (nil = skip,
1204
+ # env-only callers stay cheap): the row's last skip, when the schedule
1205
+ # was created and has one — visible in the Studio, named here too.
1206
+ def schedule_data_findings(profile, decl)
1207
+ return [] unless @schedule_store
1208
+
1209
+ row = @schedule_store.find(tenant: Insika::ScheduleEngine.tenant_for(profile),
1210
+ agent: profile.id.to_s, id: decl.id)
1211
+ return [] unless row&.last_skip
1212
+
1213
+ [Finding.new(check: "schedules", severity: :info, fix: nil,
1214
+ message: "agent '#{profile.id}' schedule '#{decl.id}': the last run was " \
1215
+ "SKIPPED at #{row.last_skip['at']} — reason " \
1216
+ "#{row.last_skip['reason'].inspect} (overlap = the previous " \
1217
+ "run still live; budget = a hard cap reached; late = the " \
1218
+ "window was missed by the no-catch-up policy).")]
1219
+ end
1220
+
979
1221
  # Data-age findings, gated on the optional collaborators (nil = skip,
980
1222
  # env-only callers stay cheap).
981
1223
  def followup_data_findings(profile, decl)
@@ -1062,6 +1304,27 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1062
1304
  stale: @proposal_store.stale(limit: 10_000).size }
1063
1305
  end
1064
1306
 
1307
+ # the in-session compaction check (RFC-0044): platform-gated,
1308
+ # so ONE line — enabled with no resolvable model (no compaction.model, no
1309
+ # platform utility_model) can never run; the warn is the same "declared
1310
+ # but dead" signal as check_distill's (the engine never guesses a model).
1311
+ def check_compaction
1312
+ return [] unless @settings_store
1313
+
1314
+ settings = @settings_store.get
1315
+ config = settings["compaction"] || {}
1316
+ return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
1317
+
1318
+ if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
1319
+ [Finding.new(check: "compaction", severity: :warn, fix: nil,
1320
+ message: "in-session compaction is enabled but has no model slot — it will " \
1321
+ "never run (set compaction.model or the platform utility_model).")]
1322
+ else
1323
+ [ok("compaction", "in-session compaction on — keep_last #{config['keep_last']}, " \
1324
+ "compact_after #{config['compact_after']}")]
1325
+ end
1326
+ end
1327
+
1065
1328
  # the harvest check — per profile WITH a harvest hash:
1066
1329
  # declared-without-model warn (D12), no grounding matcher warn (D3),
1067
1330
  # malformed negative list error (D4), else ok with the pending counts.
@@ -11,11 +11,12 @@ module Insika
11
11
  # exactly like any other pack. That is what makes `#profile` identical to a
12
12
  # hand-written equivalent pack (the parity spec) — there is only one path.
13
13
  class Definition
14
- attr_reader :pack, :runtime_options
14
+ attr_reader :pack, :runtime_options, :mcp_instances
15
15
 
16
- def initialize(pack:, runtime: {})
16
+ def initialize(pack:, runtime: {}, mcp_instances: [])
17
17
  @pack = pack
18
18
  @runtime_options = runtime
19
+ @mcp_instances = mcp_instances
19
20
  end
20
21
 
21
22
  def id = pack.config[:id].to_s
@@ -14,8 +14,6 @@ module Insika
14
14
  # Built lazily by Definition#runtime, so `require "insika"` never pays for
15
15
  # ruby_llm or the HTTP server.
16
16
  class Runtime
17
- TERMINAL = %i[task_completed task_failed task_cancelled].freeze
18
-
19
17
  # graph: the assembled Wiring::Graph::Result; pack: the PRIMARY imported
20
18
  # Pack; packs: every imported Pack (one per agent — a System has N, a
21
19
  # Definition has one). All read by the server boot and the specs.
@@ -40,6 +38,11 @@ module Insika
40
38
  # rather than reading a process-wide singleton at call time.
41
39
  @llm = build_llm_context
42
40
  @graph = build_graph
41
+ # the dispatch+drain seam `#chat`/`#run_workflow` delegate to —
42
+ # `Insika::Wiring::GraphChat`, shared with `config/deployment.rb`'s own
43
+ # registration of `run_persona_eval` (see `register_persona_eval_tool`
44
+ # below).
45
+ @chat_runtime = Insika::Wiring::GraphChat.new(graph: @graph)
43
46
  seed_default_model
44
47
  import_packs
45
48
  end
@@ -62,12 +65,8 @@ module Insika
62
65
  # one-shot; a value threads multi-turn memory (created on first use).
63
66
  # `agent:` targets one agent of a system (default: the primary).
64
67
  def chat(message, session_id: nil, timeout: nil, agent: nil)
65
- command = Insika::Command.build(
66
- :send_message,
67
- { agent: (agent || @definition.id).to_s, message: message, session_id: session_id },
68
- transport: :cli
69
- )
70
- run_command(command, session_id: session_id, timeout: timeout)[:text]
68
+ @chat_runtime.chat(message, agent: (agent || @definition.id).to_s,
69
+ session_id: session_id, timeout: timeout)
71
70
  end
72
71
 
73
72
  # Triggers a declared workflow and returns its OUTPUT (the typed value the
@@ -78,7 +77,7 @@ module Insika
78
77
  command = Insika::Command.build(
79
78
  :trigger_workflow, { workflow: name, agent: agent, input: input }, transport: :cli
80
79
  )
81
- outcome = run_command(command, session_id: nil, timeout: timeout)
80
+ outcome = @chat_runtime.run_command(command, session_id: nil, timeout: timeout)
82
81
  outcome.key?(:output) ? outcome[:output] : outcome[:text]
83
82
  end
84
83
 
@@ -93,70 +92,6 @@ module Insika
93
92
 
94
93
  private
95
94
 
96
- # Dispatch + drain, shared by a turn and a workflow run: subscribe BEFORE
97
- # dispatching (the fiber may emit eagerly), bind to the task, read to the
98
- # terminal event. A synchronous handler error (unknown agent/workflow, bad
99
- # input against the schema) propagates from `dispatch` untouched — the
100
- # caller sees the real ValidationError/NotFoundError, not a turn failure.
101
- def run_command(command, session_id:, timeout:)
102
- require "async"
103
- outcome = {}
104
- rejected = nil
105
- Async do |task|
106
- serving = !session_id.nil?
107
- @graph.executor.supervised = true if serving
108
- ensure_session(session_id) if session_id
109
- sub = @graph.event_stream.subscribe
110
- res = begin
111
- @graph.bus.dispatch(command)
112
- rescue StandardError => e
113
- # CAPTURED, not re-raised inside the reactor: letting it escape the
114
- # Async block logs "Task may have ended with unhandled exception" —
115
- # alarming noise for a DOCUMENTED rejection (a schema violation, an
116
- # unknown agent). Re-raised verbatim below, outside the reactor.
117
- sub.close
118
- rejected = e
119
- next
120
- end
121
- sub.bind(task_id: res[:task_id])
122
- drain(sub, outcome, task, timeout)
123
- teardown_serving if serving
124
- end.wait
125
- raise rejected if rejected
126
- raise Insika::Error, "turn #{outcome[:error]}" if outcome[:error]
127
-
128
- outcome
129
- end
130
-
131
- # Reads the stream until this turn reaches a terminal event, then stops.
132
- # A workflow run also carries its typed OUTPUT, which is not the turn text.
133
- def drain(sub, outcome, task, timeout)
134
- reader = task.async do
135
- sub.each do |ev|
136
- case ev.type
137
- when :task_completed then outcome[:text] = ev.data[:content].to_s
138
- when :workflow_completed then outcome[:output] = ev.data[:output]
139
- when :task_failed then outcome[:error] = "failed: #{ev.data[:message] || ev.data[:error]}"
140
- when :task_cancelled then outcome[:error] = "cancelled"
141
- end
142
- sub.close if TERMINAL.include?(ev.type)
143
- end
144
- end
145
- timeout ? task.with_timeout(timeout) { reader.wait } : reader.wait
146
- rescue Async::TimeoutError
147
- outcome[:error] = "timed out after #{timeout}s"
148
- sub.close
149
- end
150
-
151
- def ensure_session(session_id)
152
- @graph.session_store.find(session_id) || @graph.session_store.create(id: session_id, vars: {})
153
- end
154
-
155
- def teardown_serving
156
- @graph.executor.stop_session_actors
157
- @graph.executor.instance_variable_get(:@supervisor)&.stop
158
- end
159
-
160
95
  # --- graph assembly (mirrors config/deployment.rb, generically) ------
161
96
 
162
97
  def build_graph
@@ -183,6 +118,20 @@ module Insika
183
118
  )
184
119
  register_authoring_commands(graph, c)
185
120
  register_workflows(graph)
121
+ import_mcp_declarations(c)
122
+ # `run_persona_eval` (C3.1): the SAME registration `config/deployment.rb`
123
+ # does for its own graph — see `Wiring::Graph.register_persona_eval_tool`.
124
+ # One implementation, both roots; this one just already has its
125
+ # `golden_store`/`settings_store` at hand from `build_components`.
126
+ Insika::Wiring::Graph.register_persona_eval_tool(
127
+ graph, golden_store: Insika::GoldenStore.new(config_store: c[:config_store]),
128
+ settings_store: c[:settings_store], llm: @llm
129
+ )
130
+ # Same boot step the server roots run: an installed `insika-plugin-*`
131
+ # gem (or INSIKA_PLUGIN_DIR) extends a DSL-run agent too — a plugin
132
+ # works everywhere or it is not a plugin. No bundled root here: the DSL
133
+ # runs outside the repo checkout.
134
+ Insika::Wiring::Graph.load_plugins(graph)
186
135
  graph
187
136
  end
188
137
 
@@ -210,15 +159,42 @@ module Insika
210
159
  ))
211
160
  end
212
161
 
162
+ # Upserts every DSL-declared `mcp` instance into the McpStore at boot.
163
+ # Code is the TEMPLATE — transport/command/args/url/
164
+ # description always follow the declaration — but `enabled` and the
165
+ # credentials (`env`/`headers`) are the operator's once the instance
166
+ # exists: an already-present record's own values are re-sent verbatim
167
+ # instead of the DSL's, so a Studio/CLI/API edit made after boot is never
168
+ # clobbered by the next restart (motor-vs-forja: the store's DATA wins).
169
+ def import_mcp_declarations(c)
170
+ declared = Array(@definition.mcp_instances)
171
+ return if declared.empty?
172
+
173
+ mcp_store = c[:mcp_store]
174
+ declared.each do |decl|
175
+ attrs = decl.dup
176
+ existing = mcp_store.get_raw(attrs[:name])
177
+ if existing
178
+ attrs[:enabled] = existing["enabled"]
179
+ attrs[:env] = existing["env"]
180
+ attrs[:headers] = existing["headers"]
181
+ end
182
+ mcp_store.upsert(attrs)
183
+ end
184
+ end
185
+
213
186
  def build_components(backend, spine)
214
187
  config_store = Insika::ConfigStore.new(store: backend)
215
188
  settings_store = Insika::SettingsStore.new(config_store: config_store)
216
189
  provider_store = Insika::LLMProviderStore.new(config_store: config_store)
217
190
  tool_store = Insika::ToolStore.new(config_store: config_store)
218
191
  skill_store = Insika::SkillStore.new(config_store: config_store)
192
+ mcp_store = Insika::McpStore.new(config_store: config_store)
193
+ mcp_tool_registry = Insika::McpToolRegistry.new(mcp_store: mcp_store)
219
194
  tool_registry = Insika::OverlayToolRegistry.new(
220
195
  base: spine.code_tool_registry, tool_store: tool_store,
221
- http: Insika::HttpClient.new, event_stream: spine.event_stream
196
+ http: Insika::HttpClient.new, event_stream: spine.event_stream,
197
+ mcp_registry: mcp_tool_registry
222
198
  )
223
199
  {
224
200
  backend: backend, config_store: config_store,
@@ -227,7 +203,7 @@ module Insika
227
203
  agent_file_store: Insika::AgentFileStore.new(config_store: config_store),
228
204
  skill_store: skill_store, tool_store: tool_store,
229
205
  system_file_store: Insika::SystemFileStore.new(config_store: config_store),
230
- mcp_store: Insika::McpStore.new(config_store: config_store),
206
+ mcp_store: mcp_store, mcp_tool_registry: mcp_tool_registry,
231
207
  tool_trace_store: Insika::ToolTraceStore.new(store: backend),
232
208
  context_trace_store: Insika::ContextTraceStore.new(store: backend),
233
209
  # the per-AGENT cache-hit series (the Studio agent-detail card).
@@ -252,15 +228,20 @@ module Insika
252
228
  [
253
229
  Insika::Context::Providers::Request.new,
254
230
  Insika::Context::Providers::Prompt.new(
255
- base: "", files: [], catalog: c[:prompt_catalog],
231
+ base: "", catalog: c[:prompt_catalog],
256
232
  agent_files: c[:agent_file_store], system_files: c[:system_file_store]
257
233
  ),
258
234
  Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
259
235
  Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
260
236
  Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
261
237
  Insika::Context::Providers::Memory.new(store: spine.memory_store),
238
+ Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
239
+ # Session briefing: read path. Inert for agents without briefing_fields.
240
+ # Before Session, so the durable head renders above the transcript and
241
+ # the tail recitation lands after it.
242
+ Insika::Context::Providers::Briefing.new(session_store: spine.session_store),
262
243
  Insika::Context::Providers::Session.new(session_store: spine.session_store)
263
- ].freeze
244
+ ] # NOT frozen: load_plugins appends plugin providers at boot
264
245
  end
265
246
 
266
247
  # The runtime-authoring surface a pack import needs (create_agent +
@@ -288,14 +269,18 @@ module Insika
288
269
  bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
289
270
  bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
290
271
  bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
291
- bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], event_stream: es))
292
- bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], event_stream: es))
272
+ bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
273
+ bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
274
+ bus.register(:refresh_mcp_tools, Insika::Commands::RefreshMcpTools.new(mcp_registry: c[:mcp_tool_registry], event_stream: es))
293
275
  bus.register(:write_system_file, Insika::Commands::WriteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
294
276
  bus.register(:delete_system_file, Insika::Commands::DeleteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
295
277
  bus.register(:restore_system_file, Insika::Commands::RestoreSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
296
278
  bus.register(:write_data_tool, Insika::Commands::WriteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
297
279
  bus.register(:delete_data_tool, Insika::Commands::DeleteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
298
280
  bus.register(:restore_data_tool, Insika::Commands::RestoreDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
281
+ bus.register(:write_concept, Insika::Commands::WriteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
282
+ bus.register(:delete_concept, Insika::Commands::DeleteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
283
+ bus.register(:restore_concept, Insika::Commands::RestoreConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
299
284
  end
300
285
 
301
286
  # --- RubyLLM configuration ------------------------------------------
@@ -51,6 +51,10 @@ module Insika
51
51
  Insika::Shutdown.install(executor: @graph.executor)
52
52
  Async do
53
53
  @graph.executor.supervised = true # serving mode: turns survive disconnects
54
+ # forces the tick/alert/distill/harvest supervisor up now — otherwise a
55
+ # deployment with only scheduled agents (no live chat) never ticks until
56
+ # unrelated traffic happens to hit turn_parent first.
57
+ @graph.executor.start_supervisor!
54
58
  Insika::Telemetry.attach(event_stream: @graph.event_stream, recorder: telemetry)
55
59
  Async::HTTP::Server.new(middleware, endpoint).run
56
60
  end
@@ -122,10 +126,28 @@ module Insika
122
126
  # (nil = the page renders the refusal/empty states).
123
127
  harvest_store: @graph.harvest_store,
124
128
  harvest_criterion: harvest_criterion,
125
- negative_list: harvest_negative_list
129
+ negative_list: harvest_negative_list,
130
+ # the Knowledge page reads the store directly; its
131
+ # mutations (write/delete/restore) dispatch bus commands.
132
+ knowledge_store: @graph.knowledge_store,
133
+ # the Artifacts tab reads the report store; the signing
134
+ # config (nil without INSIKA_ARTIFACT_SIGNING_KEY) decides whether a
135
+ # signed sharing link exists at all.
136
+ artifact_store: @graph.artifact_store,
137
+ artifact_signing: artifact_signing
126
138
  )
127
139
  end
128
140
 
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
+
129
151
  # The frozen criterion — best-effort at boot: a bare install
130
152
  # without the env var renders the ruler's hole instead of
131
153
  # crashing the Studio.