insika 0.3.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  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 +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -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 +73 -16
  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 +22 -2
  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/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ module Demo
8
+ # Populates one instance with enough realistic-looking data to see every
9
+ # loop working at once: a funnel with a frozen baseline, follow-ups in all
10
+ # four states, refinement runs across the lifecycle, pending + resolved
11
+ # approvals, distillation proposals/facts, and a golden set with a
12
+ # baseline. Writes ONLY through the same domain-store APIs a real turn
13
+ # would use (OutcomeStore#create + FunnelFold, FollowupStore#create +
14
+ # transitions, ...) — there is no bulk/bypass path, by design (D1: the
15
+ # engine never hard-codes a shortcut for its own demo).
16
+ #
17
+ # `force: false` (default) is a no-op once the demo agent exists — safe
18
+ # to run more than once. `force: true` re-seeds on top: it re-recomputes
19
+ # the funnel cleanly (FunnelFold#recompute always wipes its own pair
20
+ # first) but APPENDS a fresh batch of followups/refinement runs/
21
+ # approvals/proposals/goldens, because none of those stores expose a
22
+ # scoped bulk-delete that a shared "platform" tenant could safely call
23
+ # without risking another agent's data (FollowupStore#purge/
24
+ # ProposalStore#purge are TENANT-wide, not per-agent).
25
+ class Seeder
26
+ FUNNEL_DAYS = 40
27
+
28
+ def initialize(profiles:, store:, session_store:, task_store:, outcome_store:,
29
+ funnel_store:, followup_store:, refinement_store:,
30
+ pending_action_store:, proposal_store:, memory_store:,
31
+ golden_store:, baseline_store:, event_stream:)
32
+ @profiles = profiles
33
+ @store = store
34
+ @session_store = session_store
35
+ @task_store = task_store
36
+ @outcome_store = outcome_store
37
+ @funnel_store = funnel_store
38
+ @followup_store = followup_store
39
+ @refinement_store = refinement_store
40
+ @pending_action_store = pending_action_store
41
+ @proposal_store = proposal_store
42
+ @memory_store = memory_store
43
+ @golden_store = golden_store
44
+ @baseline_store = baseline_store
45
+ @event_stream = event_stream
46
+ end
47
+
48
+ # -> { seeded: false, reason:, agent: } | { seeded: true, agent:, counts: {…} }
49
+ def seed!(force: false)
50
+ existing = @profiles.fetch(Demo::AGENT_ID)
51
+ return { seeded: false, reason: "already_seeded", agent: Demo::AGENT_ID } if existing && !force
52
+
53
+ # A distinct suffix per call: `force: true` on top of an existing
54
+ # batch would otherwise collide on FollowupStore's own dedup rule (one
55
+ # pending record per tenant+agent+customer+reason) and raise.
56
+ @batch = SecureRandom.hex(3)
57
+ seed_agent!(existing)
58
+ # `funnel_outcomes` runs LAST: `seed_followups!` writes one extra
59
+ # "purchased" outcome (the nudge's conversion, for the A/B card) —
60
+ # the fold has to see it, or it sits un-folded until some later pass.
61
+ counts = {
62
+ followups: seed_followups!,
63
+ refinement_runs: seed_refinement!,
64
+ approvals: seed_approvals!,
65
+ distillation_proposals: seed_distillation!,
66
+ golden_cases: seed_evals!,
67
+ funnel_outcomes: seed_sessions_and_funnel!
68
+ }
69
+ { seeded: true, agent: Demo::AGENT_ID, counts: counts }
70
+ end
71
+
72
+ private
73
+
74
+ # --- agent -----------------------------------------------------------
75
+
76
+ def seed_agent!(existing)
77
+ handler = existing ? Insika::Commands::UpdateAgent.new(profile_source: @profiles, event_stream: @event_stream)
78
+ : Insika::Commands::CreateAgent.new(profile_source: @profiles, event_stream: @event_stream)
79
+ type = existing ? :update_agent : :create_agent
80
+ handler.call(Insika::Command.build(type, Demo::AGENT_ATTRS))
81
+ end
82
+
83
+ # --- funnel: FUNNEL_DAYS of outcomes, folded, then frozen -------------
84
+
85
+ def seed_sessions_and_funnel!
86
+ declaration = Insika::FunnelDeclaration.parse!(Demo::AGENT_ATTRS[:funnel])
87
+ created = 0
88
+ FUNNEL_DAYS.downto(1) do |days_ago|
89
+ at = Time.now.utc - (days_ago * 86_400)
90
+ day_counts(at).each { |stage, n| created += record_stage(stage, n, at) }
91
+ end
92
+
93
+ Insika::FunnelFold.new(outcome_store: @outcome_store, funnel_store: @funnel_store,
94
+ profiles: @profiles, store: @store)
95
+ .recompute(tenant: nil, agent: Demo::AGENT_ID, declaration: declaration)
96
+ Insika::Commands::FreezeFunnelBaseline.new(funnel_store: @funnel_store, profiles: @profiles,
97
+ event_stream: @event_stream)
98
+ .call(Insika::Command.build(:freeze_funnel_baseline, { agent: Demo::AGENT_ID }))
99
+ created
100
+ end
101
+
102
+ # A believable e-commerce dropout: each stage keeps a random fraction
103
+ # of the one before it.
104
+ def day_counts(at)
105
+ greeted = rand(4..9)
106
+ browsing = (greeted * rand(0.5..0.7)).round
107
+ cart = (browsing * rand(0.3..0.45)).round
108
+ checkout = (cart * rand(0.45..0.65)).round
109
+ purchased = (checkout * rand(0.55..0.8)).round
110
+ { "greeted" => greeted, "browsing" => browsing, "cart_started" => cart,
111
+ "checkout_started" => checkout, "purchased" => purchased }
112
+ end
113
+
114
+ def record_stage(stage, count, at)
115
+ count.times do
116
+ value = stage == "purchased" ? rand(60.0..320.0).round(2) : 0.0
117
+ @outcome_store.create(tenant: nil, agent: Demo::AGENT_ID, outcome: stage, value: value, at: at)
118
+ end
119
+ count
120
+ end
121
+
122
+ # --- follow-ups: one per state ----------------------------------------
123
+
124
+ def seed_followups!
125
+ pending_followup!(customer: batch_customer(1), reason: "cart_abandoned", arm: "control")
126
+
127
+ nudge_session = new_task_session!("I'll take the blue sneakers after all")
128
+ fire_followup!(customer: batch_customer(2), reason: "cart_abandoned", arm: "nudge",
129
+ session: nudge_session[:session], task: nudge_session[:task])
130
+ @outcome_store.create(tenant: nil, agent: Demo::AGENT_ID, session_id: nudge_session[:session].id,
131
+ outcome: "purchased", value: 89.9)
132
+
133
+ control_session = new_task_session!("thinking about it, maybe later")
134
+ fire_followup!(customer: batch_customer(3), reason: "cart_abandoned", arm: "control",
135
+ session: control_session[:session], task: control_session[:task])
136
+
137
+ cancelled = pending_followup!(customer: batch_customer(4), reason: "no_response", arm: "nudge")
138
+ @followup_store.cancel(id: cancelled.id)
139
+
140
+ blocked = pending_followup!(customer: batch_customer(5), reason: "reminder", arm: "nudge")
141
+ @followup_store.block(id: blocked.id, reason: "quiet_hours")
142
+
143
+ 5
144
+ end
145
+
146
+ # Distinct per seed! call (see @batch) — the store refuses two pending
147
+ # records for the same (tenant, agent, customer, reason) tuple, and a
148
+ # force reseed must not collide with the previous batch's still-pending
149
+ # one.
150
+ def batch_customer(n) = "demo-customer-#{n}-#{@batch}"
151
+
152
+ def pending_followup!(customer:, reason:, arm:)
153
+ @followup_store.create(tenant: nil, agent: Demo::AGENT_ID, customer: customer,
154
+ session_id: @session_store.create.id, at: Time.now.utc + 3600,
155
+ reason: reason, arm: arm, transport: "web")
156
+ end
157
+
158
+ def fire_followup!(customer:, reason:, arm:, session:, task:)
159
+ record = @followup_store.create(tenant: nil, agent: Demo::AGENT_ID, customer: customer,
160
+ session_id: session.id, at: Time.now.utc + 3600,
161
+ reason: reason, arm: arm, transport: "web")
162
+ @followup_store.transition_fired(id: record.id, task_id: task.id)
163
+ finish_task!(task)
164
+ end
165
+
166
+ # --- refinement: one run per stage of the lifecycle -------------------
167
+
168
+ def seed_refinement!
169
+ awaiting_approval_run!
170
+ applied_run!
171
+ rejected_run!
172
+ no_findings_run!
173
+ 4
174
+ end
175
+
176
+ def awaiting_approval_run!
177
+ run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 50 })
178
+ @refinement_store.complete(run.id, findings: [repeated_price_finding])
179
+ candidate = discipline_candidate
180
+ @refinement_store.gating(run.id, candidate: candidate)
181
+ @refinement_store.gated(run.id, report: passing_gate_report(candidate))
182
+ end
183
+
184
+ def applied_run!
185
+ run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 50 })
186
+ @refinement_store.complete(run.id, findings: [repeated_price_finding])
187
+ candidate = discipline_candidate
188
+ @refinement_store.gating(run.id, candidate: candidate)
189
+ @refinement_store.gated(run.id, report: passing_gate_report(candidate))
190
+ @refinement_store.resolve(run.id, decision: :applied, operator: "demo-seed",
191
+ note: "Looks safe — applying to AGENTS.md.")
192
+ end
193
+
194
+ def rejected_run!
195
+ run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 30 })
196
+ @refinement_store.complete(run.id, findings: [off_topic_finding])
197
+ candidate = off_topic_candidate
198
+ @refinement_store.gating(run.id, candidate: candidate)
199
+ @refinement_store.gated(run.id, report: failing_gate_report(candidate))
200
+ end
201
+
202
+ def no_findings_run!
203
+ run = @refinement_store.create(agent_id: Demo::AGENT_ID, window: { "last_sessions" => 20 })
204
+ @refinement_store.complete(run.id, findings: [])
205
+ end
206
+
207
+ def repeated_price_finding
208
+ { "kind" => "repeated_price_without_stock_check", "count" => 4,
209
+ "title" => "Repeats a price without checking stock",
210
+ "detail" => "asked the price of the same item twice; the agent repeated it verbatim " \
211
+ "without a stock check" }
212
+ end
213
+
214
+ def off_topic_finding
215
+ { "kind" => "answers_off_topic_questions", "count" => 2,
216
+ "title" => "Answers questions unrelated to the store",
217
+ "detail" => "answered a question about the weather instead of redirecting to store topics" }
218
+ end
219
+
220
+ def discipline_candidate
221
+ { "id" => SecureRandom.uuid, "proposer" => "demo-seed",
222
+ "rationale" => "Customers who ask about the same product twice get the same price restated " \
223
+ "without a stock check — a stale price can be quoted after a sellout.",
224
+ "edits" => [{ "file" => "AGENTS.md", "op" => "append", "anchor" => nil, "before" => nil,
225
+ "after" => "Always confirm current stock before repeating a price.",
226
+ "addresses" => ["repeated_price_without_stock_check"] }],
227
+ "dropped" => [] }
228
+ end
229
+
230
+ def off_topic_candidate
231
+ { "id" => SecureRandom.uuid, "proposer" => "demo-seed",
232
+ "rationale" => "The agent occasionally answers off-topic questions instead of redirecting.",
233
+ "edits" => [{ "file" => "AGENTS.md", "op" => "append", "anchor" => nil, "before" => nil,
234
+ "after" => "Never answer questions unrelated to the store; redirect politely.",
235
+ "addresses" => ["answers_off_topic_questions"] }],
236
+ "dropped" => [] }
237
+ end
238
+
239
+ def passing_gate_report(candidate)
240
+ { "passed" => true, "candidate_id" => candidate["id"], "cases" => 12, "passed_cases" => 12,
241
+ "regressions" => [] }
242
+ end
243
+
244
+ def failing_gate_report(candidate)
245
+ { "passed" => false, "candidate_id" => candidate["id"], "cases" => 10, "passed_cases" => 7,
246
+ "regressions" => ["demo-store-off-topic"], "reason" => "3 case(s) regressed" }
247
+ end
248
+
249
+ # --- approvals: 2 pending + 1 resolved --------------------------------
250
+
251
+ def seed_approvals!
252
+ [
253
+ ["I want a refund for order #10234, the shoes don't fit", "issue_refund",
254
+ { "order_id" => "10234", "amount" => 89.9 }],
255
+ ["can you apply the WELCOME10 discount code retroactively?", "apply_discount_code",
256
+ { "order_id" => "10391", "code" => "WELCOME10" }]
257
+ ].each do |message, tool, args|
258
+ task = new_task_session!(message)[:task]
259
+ @pending_action_store.create(task_id: task.id, turn: 3, tool: tool, args: args)
260
+ finish_task!(task)
261
+ end
262
+
263
+ task = new_task_session!("please cancel order #10500 and refund me")[:task]
264
+ pending = @pending_action_store.create(task_id: task.id, turn: 2, tool: "cancel_order",
265
+ args: { "order_id" => "10500" })
266
+ @pending_action_store.resolve(pending.id, decision: :approved, operator: "demo-seed")
267
+ finish_task!(task)
268
+
269
+ 3
270
+ end
271
+
272
+ # --- distillation: proposals + one approved fact ----------------------
273
+
274
+ def seed_distillation!
275
+ session6 = distillation_session!("demo-customer-6", "I usually pay with Pix, is that ok here too?")
276
+ @proposal_store.create(tenant: nil, customer: "demo-customer-6", session_ref: session6.id,
277
+ key: "preferred_payment_method", value: "pix", confidence: 0.82, evidence: [0])
278
+
279
+ session7 = distillation_session!("demo-customer-7", "I'm always browsing the electronics section")
280
+ approved = @proposal_store.create(tenant: nil, customer: "demo-customer-7", session_ref: session7.id,
281
+ key: "favorite_category", value: "electronics", confidence: 0.9,
282
+ evidence: [0])
283
+ @proposal_store.approve(id: approved.id, operator: "demo-seed", note: "Confirmed across 3 conversations.")
284
+ @memory_store.put_fact(tenant: nil, customer: "demo-customer-7", key: "favorite_category",
285
+ value: "electronics", origin: "distilled:#{approved.id}")
286
+
287
+ session8 = distillation_session!("demo-customer-8", "I wear a size 42, I think")
288
+ rejected = @proposal_store.create(tenant: nil, customer: "demo-customer-8", session_ref: session8.id,
289
+ key: "shoe_size", value: "42", confidence: 0.4, evidence: [0])
290
+ @proposal_store.reject(id: rejected.id, operator: "demo-seed", note: "Confidence too low, likely a typo.")
291
+
292
+ 3
293
+ end
294
+
295
+ # A real Session (not a bare string) so the Facts page's evidence link
296
+ # resolves — a plain id would 404 on /studio/sessions/:id.
297
+ def distillation_session!(customer, message)
298
+ session = @session_store.create(vars: { "customer" => customer, "agent" => Demo::AGENT_ID })
299
+ @session_store.append_messages(session.id, { "role" => "user", "content" => message })
300
+ session
301
+ end
302
+
303
+ # --- evals: the golden set + a mixed pass/fail baseline ---------------
304
+
305
+ def seed_evals!
306
+ ids = Demo::GOLDEN_CASES.map { |raw| @golden_store.write(raw).id }
307
+ last = ids.size - 1
308
+ cases = ids.each_with_index.to_h do |id, i|
309
+ [id, i == last ? { "pass" => false, "score" => 0.42 } : { "pass" => true, "score" => (0.75 + i * 0.03).round(2) }]
310
+ end
311
+ @baseline_store.put(Demo::AGENT_ID, { "cases" => cases })
312
+ ids.size
313
+ end
314
+
315
+ # --- shared: a Session + one user Task, like a real turn would leave --
316
+
317
+ def new_task_session!(message)
318
+ session = @session_store.create(vars: { "customer" => SecureRandom.uuid, "agent" => Demo::AGENT_ID })
319
+ command = Insika::Command.build(:send_message, { agent: Demo::AGENT_ID, message: message })
320
+ task = @task_store.create(command: command.to_h, session_id: session.id)
321
+ { session: session, task: task }
322
+ end
323
+
324
+ # Lands a fresh task in a TERMINAL status (queued -> running ->
325
+ # completed). A seeded task must never sit at :running/:waiting/:paused
326
+ # (or even :queued) once seeding is done — Recovery's boot sweep reads
327
+ # those as an interrupted turn and either dispatches a real resume (no
328
+ # LLM configured -> a pointless failure) or fails the task outright for
329
+ # want of a checkpoint that was never real. Terminal is inert either way.
330
+ def finish_task!(task)
331
+ @task_store.transition(task.id, to: :running)
332
+ @task_store.transition(task.id, to: :completed)
333
+ end
334
+ end
335
+ end
336
+ end
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,11 @@ 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_harvest check_schedules check_guardrail_corpora]
176
183
 
177
184
  def safe(check)
178
185
  Array(send(check))
@@ -336,6 +343,54 @@ module Insika
336
343
  message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
337
344
  end
338
345
 
346
+ # WEB_CONCURRENCY>1 is a legitimate config (docs/DEPLOY.md "The process
347
+ # model") — but it silently drops per-worker session guarantees
348
+ # (FIFO/collect/steer) unless the operator has sticky routing per session in
349
+ # front, and nothing else in the deployment surfaces that drift. This is
350
+ # exactly how a staging service ended up at N=4 unnoticed and leaked a reply
351
+ # across sessions. The engine cannot see whether sticky routing exists off
352
+ # Railway, so that case only warns — N>1 stays a valid choice for an
353
+ # operator who put a real sticky proxy in front. On Railway it is not a
354
+ # valid choice TODAY: Railway's own docs say it "does not support sticky
355
+ # sessions" and randomly distributes traffic — there is no way to satisfy
356
+ # the precondition on this platform, so this errors instead of warning.
357
+ # RAILWAY_ENVIRONMENT_NAME is injected into every Railway deployment
358
+ # (docs.railway.com/variables/reference), so its presence is a reliable
359
+ # "are we on Railway" signal.
360
+ def check_web_concurrency
361
+ n = @env["WEB_CONCURRENCY"].to_i
362
+ return [ok("web-concurrency", "WEB_CONCURRENCY=1 (default) — session semantics hold cluster-wide")] if n <= 1
363
+
364
+ if router_detected?
365
+ return [ok("web-concurrency", "WEB_CONCURRENCY=#{n} behind a detected insika-router " \
366
+ "sticky routing satisfies the precondition")]
367
+ end
368
+
369
+ if Insika::Coercion.present?(@env["RAILWAY_ENVIRONMENT_NAME"])
370
+ return [Finding.new(check: "web-concurrency", severity: :error,
371
+ message: "WEB_CONCURRENCY=#{n} on Railway — Railway does not support sticky " \
372
+ "sessions (docs.railway.com/deployments/scaling), so per-session " \
373
+ "FIFO/collect/steer WILL break across workers with no way to fix it " \
374
+ "at the routing layer; set WEB_CONCURRENCY=1, or run insika-router " \
375
+ "in front of N local workers instead", fix: nil)]
376
+ end
377
+
378
+ [Finding.new(check: "web-concurrency", severity: :warn,
379
+ message: "WEB_CONCURRENCY=#{n} — per-session FIFO/collect/steer only hold on the worker " \
380
+ "that owns the session; make sure sticky routing per session sits in front (e.g. " \
381
+ "insika-router, set INSIKA_ROUTER_BACKENDS/_DNS), or accept " \
382
+ "per-worker best-effort (docs/DEPLOY.md \"The process model\")", fix: nil)]
383
+ end
384
+
385
+ # A router is "detected", not verified — the doctor cannot see whether a
386
+ # process at these addresses actually runs `insika-router`, only that the
387
+ # operator configured one. Same precedent as every other
388
+ # env-based capability check in this file.
389
+ def router_detected?
390
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS"]) ||
391
+ Insika::Coercion.present?(@env["INSIKA_ROUTER_BACKENDS_DNS"])
392
+ end
393
+
339
394
  # the soak envelope (a deployment-side file). Absent is :info — not every
340
395
  # deployment soaks, and the tooling is optional. Present-and-broken is
341
396
  # :error — an envelope that exists but does not parse is a pre-declaration
@@ -694,11 +749,50 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
694
749
  def check_data_tools
695
750
  return [] unless @tool_store
696
751
 
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?
752
+ raws = @tool_store.all_raw
753
+ broken = raws.filter_map { |raw| broken_tool(raw) }
754
+ legacy = raws.select { |raw| raw["group"].to_s.start_with?("mcp:") }.map { |raw| legacy_mcp_snapshot(raw) }
755
+ base = broken.empty? ? [ok("data-tools", "#{raws.length} data tool(s): every definition valid")] : broken
756
+ base + legacy
757
+ end
758
+
759
+ # A data-tool in an `mcp:*` group was ingested by the
760
+ # RETIRED snapshot path (McpToolIngestor) — the live registry now serves
761
+ # that same MCP instance's tools directly. Still executes fine (never
762
+ # removed automatically), just a duplicate worth cleaning up.
763
+ def legacy_mcp_snapshot(raw)
764
+ Finding.new(check: "data-tools", severity: :info, fix: nil,
765
+ message: "data tool '#{raw["name"]}' (group #{raw["group"]}) is a legacy MCP " \
766
+ "snapshot — that instance's tools now run LIVE via the MCP registry; " \
767
+ "this frozen copy still works but duplicates it, safe to remove.")
768
+ end
769
+
770
+ # An http/sse instance whose credentials still sit under `env`
771
+ # (the pre-RFC meaning) is READ as `headers` (McpStore#raw), but the
772
+ # record on disk is unchanged until the operator re-saves it — flag it so
773
+ # it doesn't linger silently. A stdio instance that is enabled but
774
+ # INSIKA_MCP_STDIO is not set will save fine but refuse to start.
775
+ def check_mcp
776
+ return [] unless @mcp_store
777
+
778
+ raws = @mcp_store.all_raw
779
+ gated = raws.select do |r|
780
+ r["transport"].to_s == "stdio" && r["enabled"] &&
781
+ !Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_MCP_STDIO", @env))
782
+ end
783
+ findings = @mcp_store.legacy_header_names.map do |name|
784
+ Finding.new(check: "mcp", severity: :warn, fix: nil,
785
+ message: "MCP instance '#{name}' still stores credentials under 'env' — " \
786
+ "re-save it so they move to 'headers' (env is now stdio-only).")
787
+ end
788
+ findings += gated.map do |r|
789
+ Finding.new(check: "mcp", severity: :info, fix: nil,
790
+ message: "MCP instance '#{r["name"]}' is stdio and enabled, but INSIKA_MCP_STDIO " \
791
+ "is not set — it will refuse to start.")
792
+ end
793
+ return [ok("mcp", "#{raws.length} MCP instance(s): no legacy env-as-headers records")] if findings.empty?
700
794
 
701
- broken
795
+ findings
702
796
  end
703
797
 
704
798
  # the doctor cannot run a turn, so it cannot prove purity — it
@@ -976,6 +1070,80 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
976
1070
  e.message
977
1071
  end
978
1072
 
1073
+ # the recurring-schedule check — declarations
1074
+ # validated where they are declared (each one through the SAME
1075
+ # Insika::Schedule parser the engine fires on, never a second opinion),
1076
+ # plus the data read that answers "what is being skipped right now".
1077
+ # A bare install (no schedule on any profile) reports NOTHING.
1078
+ def check_schedules
1079
+ return [] unless @profile_source
1080
+
1081
+ declared = @profile_source.all.select do |profile|
1082
+ profile.respond_to?(:schedules) && Array(profile.schedules).any?
1083
+ end
1084
+ return [] if declared.empty?
1085
+
1086
+ declared.flat_map do |profile|
1087
+ Array(profile.schedules).flat_map do |schedule|
1088
+ decl = Insika::Schedule.parse(schedule)
1089
+ if decl.nil?
1090
+ [Finding.new(check: "schedules", severity: :error, fix: nil,
1091
+ message: "agent '#{profile.id}': malformed schedule — " \
1092
+ "#{schedule_defect(schedule)}. The engine will never " \
1093
+ "fire it until this is fixed.")]
1094
+ else
1095
+ ok_finding = schedule_ok(profile, decl)
1096
+ [ok_finding] + schedule_data_findings(profile, decl)
1097
+ end
1098
+ end
1099
+ end
1100
+ end
1101
+
1102
+ # The ok finding for one valid declaration — plus the warning that
1103
+ # names the cadence ceiling: the engine claims ONE window per pass, so an
1104
+ # `every` shorter than the claim window cannot fire more often than it.
1105
+ def schedule_ok(profile, decl)
1106
+ trigger = decl.cron ? "cron #{decl.cron} <#{decl.tz}>" : "every #{decl.every}s"
1107
+ floor = Insika::ScheduleEngine::DEFAULT_WINDOW
1108
+ if !decl.cron.nil? || decl.every >= floor
1109
+ ok("schedules",
1110
+ "agent '#{profile.id}': schedule '#{decl.id}' — #{trigger}, " \
1111
+ "#{decl.fixed_session? ? "a fixed standing session" : 'a new session per run'}, " \
1112
+ "#{decl.enabled ? 'enabled' : 'disabled'}")
1113
+ else
1114
+ Finding.new(check: "schedules", severity: :warn, fix: nil,
1115
+ message: "agent '#{profile.id}': schedule '#{decl.id}' declares " \
1116
+ "every #{decl.every}s, but the engine fires at most once per " \
1117
+ "#{floor}s claim window — the effective cadence is one run " \
1118
+ "per ~#{floor}s, not one per #{decl.every}s.")
1119
+ end
1120
+ end
1121
+
1122
+ # The named defect, for the error message — the engine's parser's words.
1123
+ def schedule_defect(hash)
1124
+ Insika::Schedule.parse!(hash).to_s
1125
+ rescue Insika::ValidationError => e
1126
+ e.message
1127
+ end
1128
+
1129
+ # Data-age findings, gated on the optional collaborator (nil = skip,
1130
+ # env-only callers stay cheap): the row's last skip, when the schedule
1131
+ # was created and has one — visible in the Studio, named here too.
1132
+ def schedule_data_findings(profile, decl)
1133
+ return [] unless @schedule_store
1134
+
1135
+ row = @schedule_store.find(tenant: Insika::ScheduleEngine.tenant_for(profile),
1136
+ agent: profile.id.to_s, id: decl.id)
1137
+ return [] unless row&.last_skip
1138
+
1139
+ [Finding.new(check: "schedules", severity: :info, fix: nil,
1140
+ message: "agent '#{profile.id}' schedule '#{decl.id}': the last run was " \
1141
+ "SKIPPED at #{row.last_skip['at']} — reason " \
1142
+ "#{row.last_skip['reason'].inspect} (overlap = the previous " \
1143
+ "run still live; budget = a hard cap reached; late = the " \
1144
+ "window was missed by the no-catch-up policy).")]
1145
+ end
1146
+
979
1147
  # Data-age findings, gated on the optional collaborators (nil = skip,
980
1148
  # env-only callers stay cheap).
981
1149
  def followup_data_findings(profile, decl)
@@ -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