insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # `insika doctor` (— OpenClaw's "strict config + doctor --fix",
5
+ # "the repo's best productization discipline"). A read-only diagnosis of a
6
+ # deployment's configuration that turns the scattered, silent boot warnings into
7
+ # ONE structured report — and, with `--fix`, applies the safe autofixes.
8
+ #
9
+ # Every check is DATA (config over convention): an id, a title, and a lambda that
10
+ # inspects the injected collaborators and returns Findings. A Finding may carry a
11
+ # `fix` proc; `fix!` runs those and re-diagnoses. Collaborators are optional so the
12
+ # CLI can build them straight off the durable backend (no full app boot / no
13
+ # DEEPSEEK) and an env-only caller can skip the store checks entirely.
14
+ class Doctor
15
+ # severity: :ok (green) | :info | :warn | :error. `fix` = a 0-arg proc or nil.
16
+ Finding = Data.define(:check, :severity, :message, :fix) do
17
+ def fixable? = !fix.nil?
18
+ def ok? = severity == :ok
19
+ def error? = severity == :error
20
+ def to_h = { "check" => check, "severity" => severity.to_s, "message" => message, "fixable" => fixable? }
21
+ end
22
+
23
+ # The outcome of a run: the findings + convenience rollups + renderers.
24
+ Report = Data.define(:findings) do
25
+ def ok? = errors.empty?
26
+ def errors = findings.select(&:error?)
27
+ def warnings = findings.select { |f| f.severity == :warn }
28
+ def fixable = findings.select(&:fixable?)
29
+
30
+ def to_h
31
+ { "ok" => ok?, "counts" => counts, "findings" => findings.map(&:to_h) }
32
+ end
33
+
34
+ def counts
35
+ findings.each_with_object(Hash.new(0)) { |f, acc| acc[f.severity.to_s] += 1 }
36
+ end
37
+
38
+ # Human report. `color` wraps severities in ANSI when the sink is a TTY.
39
+ def to_s(color: false)
40
+ lines = findings.map { |f| "#{glyph(f.severity, color)} [#{f.check}] #{f.message}#{' (fixable)' if f.fixable?}" }
41
+ summary =
42
+ if ok? && warnings.empty? then "config OK"
43
+ elsif ok? then "config OK — #{warnings.length} warning(s)"
44
+ else "#{errors.length} error(s), #{warnings.length} warning(s)"
45
+ end
46
+ (lines + ["", summary]).join("\n")
47
+ end
48
+
49
+ private
50
+
51
+ def glyph(severity, color)
52
+ sym = { ok: "✓", info: "•", warn: "!", error: "✗" }.fetch(severity, "•")
53
+ return sym unless color
54
+
55
+ code = { ok: 32, info: 36, warn: 33, error: 31 }.fetch(severity, 0)
56
+ "\e[#{code}m#{sym}\e[0m"
57
+ end
58
+ end
59
+
60
+ def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
61
+ agent_file_store: nil, skill_store: nil, skill_catalog: nil,
62
+ profile_source: nil, backend: nil, extra_env_specs: [])
63
+ @env = env
64
+ @settings_store = settings_store
65
+ @llm_provider_store = llm_provider_store
66
+ @tool_store = tool_store
67
+ @agent_file_store = agent_file_store
68
+ @skill_store = skill_store
69
+ @skill_catalog = skill_catalog
70
+ @profile_source = profile_source
71
+ @backend = backend
72
+ @extra_env_specs = extra_env_specs
73
+ end
74
+
75
+ # -> Report. Never raises (a broken check degrades to an :error Finding).
76
+ def run
77
+ Report.new(findings: checks.flat_map { |check| safe(check) }.freeze)
78
+ end
79
+
80
+ # Applies every fixable finding, then re-diagnoses. -> [Report before, Report after].
81
+ def fix!
82
+ before = run
83
+ before.fixable.map(&:fix).each(&:call)
84
+ [before, run]
85
+ end
86
+
87
+ private
88
+
89
+ def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
90
+ check_admin_token check_data_tools check_prompt_files check_relay_channel
91
+ check_web_widget check_skill_eager check_skill_drift]
92
+
93
+ def safe(check)
94
+ Array(send(check))
95
+ rescue StandardError => e
96
+ id = check.to_s.sub(/^check_/, "").tr("_", "-")
97
+ [Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
98
+ end
99
+
100
+ # -- checks --------------------------------------------------------
101
+
102
+ def check_env
103
+ findings = Insika::EnvSchema.validate(@env, extra: @extra_env_specs)
104
+ return [ok("env", "environment: no unknown/invalid keys in owned namespaces")] if findings.empty?
105
+
106
+ findings.map { |f| Finding.new(check: "env", severity: f.severity, message: f.message, fix: nil) }
107
+ end
108
+
109
+ def check_settings_schema
110
+ return [] unless @settings_store
111
+
112
+ pending = @settings_store.pending_migrations
113
+ return [ok("settings-schema", "settings schema at v#{Insika::SettingsStore::SCHEMA_VERSION}")] if pending.empty?
114
+
115
+ [Finding.new(check: "settings-schema", severity: :warn,
116
+ message: "settings schema at v#{@settings_store.stored_schema_version}, pending migration(s) #{pending.inspect} to v#{Insika::SettingsStore::SCHEMA_VERSION}",
117
+ fix: -> { @settings_store.migrate! })]
118
+ end
119
+
120
+ def check_default_model
121
+ return [] unless @settings_store
122
+
123
+ settings = @settings_store.get
124
+ return [ok("default-model", "platform default model: #{settings['default_model']}")] if Insika::Coercion.present?(settings["default_model"])
125
+
126
+ seed = seed_model
127
+ fix = seed && -> { @settings_store.update("default_model" => seed) }
128
+ msg = "no platform default_model set (a model-less agent has nothing to resolve; set one in Studio > Settings)"
129
+ msg += " — fixable from #{seed}" if seed
130
+ [Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
131
+ end
132
+
133
+ def check_db
134
+ unless @backend.is_a?(Insika::Stores::SQLite)
135
+ return [Finding.new(check: "db", severity: :info,
136
+ message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
137
+ end
138
+
139
+ @backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
140
+ [ok("db", "durable backend: SQLite (readable)")]
141
+ rescue StandardError => e
142
+ [Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
143
+ end
144
+
145
+ def check_llm_provider
146
+ return [] unless @llm_provider_store
147
+
148
+ configured = !Array(@llm_provider_store.apis).empty? || Insika::Coercion.present?(@env["DEEPSEEK_API_KEY"])
149
+ return [ok("llm-provider", "LLM provider configured")] if configured
150
+
151
+ [Finding.new(check: "llm-provider", severity: :warn,
152
+ message: "no LLM provider configured (set DEEPSEEK_API_KEY or add one in Studio > LLM providers) — turns will fail", fix: nil)]
153
+ end
154
+
155
+ def check_admin_token
156
+ return [ok("admin-token", "ADMIN_TOKEN set")] if Insika::Coercion.present?(@env["ADMIN_TOKEN"])
157
+
158
+ [Finding.new(check: "admin-token", severity: :warn,
159
+ message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
160
+ end
161
+
162
+ # A half-configured relay is the silent kind of broken: with only
163
+ # the deliver URL set, nothing is mounted and every inbound POST 404s; with only
164
+ # the token, the engine accepts turns it can never answer and the customer waits
165
+ # forever on a reply that is sitting in the outbox. Both halves or neither.
166
+ def check_relay_channel
167
+ token = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
168
+ url = Insika::Coercion.present?(@env["INSIKA_RELAY_DELIVER_URL"])
169
+ return [] unless token || url
170
+
171
+ if token && url
172
+ [ok("relay-channel", "relay channel mounted at /channels/relay/events")]
173
+ elsif token
174
+ [Finding.new(check: "relay-channel", severity: :warn,
175
+ message: "INSIKA_RELAY_TOKEN set without INSIKA_RELAY_DELIVER_URL — inbound is accepted but no reply can be delivered", fix: nil)]
176
+ else
177
+ [Finding.new(check: "relay-channel", severity: :warn,
178
+ message: "INSIKA_RELAY_DELIVER_URL set without INSIKA_RELAY_TOKEN — the relay channel is NOT mounted (the token is the switch)", fix: nil)]
179
+ end
180
+ end
181
+
182
+ # The widget is the one PUBLIC channel, so its misconfigurations are the ones that
183
+ # cost money rather than just failing. Three of them, in the
184
+ # order they bite: half the switch set (mounted nowhere, or mounted addressing
185
+ # nothing), and a mount with no chat rate limit anywhere — which the channel
186
+ # refuses with a 503 rather than opening, but which reads to an operator as "the
187
+ # widget is broken" unless something says why.
188
+ def check_web_widget
189
+ origins = Insika::Coercion.present?(@env["INSIKA_WIDGET_ORIGINS"])
190
+ agents = Insika::Coercion.present?(@env["INSIKA_WIDGET_AGENTS"])
191
+ return [] unless origins || agents
192
+
193
+ unless origins && agents
194
+ missing = origins ? "INSIKA_WIDGET_AGENTS" : "INSIKA_WIDGET_ORIGINS"
195
+ return [Finding.new(check: "web-widget", severity: :warn,
196
+ message: "#{missing} unset — the web widget is NOT mounted (both allowlists are the switch)", fix: nil)]
197
+ end
198
+
199
+ return [ok("web-widget", "web widget mounted at /channels/web (origins + agents allowlisted, rate limit configured)")] if platform_chat_rate_limit
200
+
201
+ # No platform default. A per-agent `limits.chat_rate_limit` still satisfies the
202
+ # gate, and the profiles are not readable from here, so this is a warning and
203
+ # not an error — but it is the likeliest reason a widget answers 503.
204
+ [Finding.new(check: "web-widget", severity: :warn,
205
+ message: "no platform edge.chat_rate_limit — the widget answers 503 unless EVERY agent in " \
206
+ "INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
207
+ end
208
+
209
+ # Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
210
+ # skills are SHARED and a flag on the skill forced one decision onto every agent
211
+ # holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
212
+ # nothing crashes, the body simply stops being in the prompt. This check is the
213
+ # only thing that says so. Its sibling half is the opposite mistake: a name in
214
+ # `skills_eager` that the agent's `skills` allowlist does not contain is
215
+ # intersected away at runtime, so the operator's intent evaporates in silence.
216
+ def check_skill_eager
217
+ findings = stale_eager_frontmatter + unreachable_eager_names
218
+ return findings if findings.any?
219
+ return [] unless @skill_store || @skill_catalog || @profile_source
220
+
221
+ [ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
222
+ end
223
+
224
+ def stale_eager_frontmatter
225
+ skill_sources.filter_map do |name, content|
226
+ next unless frontmatter_of(content).key?("eager")
227
+
228
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
229
+ message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
230
+ "Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
231
+ "(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
232
+ end
233
+ end
234
+
235
+ def unreachable_eager_names
236
+ return [] unless @profile_source
237
+
238
+ @profile_source.all.flat_map do |profile|
239
+ eager = profile.skills_eager
240
+ next [] unless eager.is_a?(Array)
241
+ next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
242
+
243
+ allowed = Array(profile.skills).map(&:to_s)
244
+ (eager.map(&:to_s) - allowed).map do |name|
245
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
246
+ message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
247
+ "the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
248
+ end
249
+ end
250
+ end
251
+
252
+ # { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
253
+ # (seed, via the catalog) and the authored store (wins — same precedence as
254
+ # SkillCatalog). The checks parse frontmatter the catalog deliberately drops
255
+ # (`eager:`), so they need the raw text; for a disk skill that is the file itself,
256
+ # and a store-overlaid skill carries a sentinel path that is not a file.
257
+ def skill_sources
258
+ @skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
259
+ end
260
+
261
+ def disk_skill_sources
262
+ return {} unless @skill_catalog
263
+
264
+ @skill_catalog.all.each_with_object({}) do |skill, acc|
265
+ acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
266
+ end
267
+ end
268
+
269
+ # The frontmatter block only; a `eager:` line in the BODY is prose, not config.
270
+ def frontmatter_of(content)
271
+ match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
272
+ match ? Insika::Frontmatter.parse(match[1]) : {}
273
+ end
274
+
275
+ # Drift between a pack's PROSE and the catalog. Three ways it happened on the
276
+ # pilot, all silent, all found by reading a customer conversation afterwards.
277
+ #
278
+ # Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
279
+ # identities. A check that has to parse prose ("this paragraph declares a count of
280
+ # six") false-positives on the first real pack and takes the doctor's credibility
281
+ # with it, which costs more than the drift it caught.
282
+ def check_skill_drift
283
+ return [] unless (@skill_store || @skill_catalog) && @profile_source
284
+
285
+ findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
286
+ return findings if findings.any?
287
+
288
+ [ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
289
+ end
290
+
291
+ # D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
292
+ # renders each skill with its triggers), so the hand-written companion has no
293
+ # reason to exist — but a pack that still carries one keeps instructing the model
294
+ # about skills the agent cannot load. Skill names are known ids, so this is a grep.
295
+ def prompt_files_naming_unallowed_skills
296
+ return [] unless @agent_file_store
297
+
298
+ catalog = skill_sources.keys
299
+ @profile_source.all.flat_map do |profile|
300
+ next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
301
+
302
+ allowed = Array(profile.skills).map(&:to_s)
303
+ orphans = catalog - allowed
304
+ next [] if orphans.empty?
305
+
306
+ @agent_file_store.list(profile.id).flat_map do |file|
307
+ body = @agent_file_store.read(profile.id, file).to_s
308
+ orphans.select { |name| mentions?(body, name) }.map do |name|
309
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
310
+ message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
311
+ "skills allowlist — the model is being told to use something it cannot load. " \
312
+ "Allow the skill, or drop the reference (the skill table is generated).")
313
+ end
314
+ end
315
+ end
316
+ end
317
+
318
+ # D2. A skill in more than one allowlist that names one of its OWN holders in its
319
+ # text is specialized text in shared clothing — the pilot served the Cacau Show
320
+ # agent three shared skills that each said "na Natura". Specialize it per agent
321
+ # (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
322
+ #
323
+ # Merchant vocabulary beyond the holders' identities is deliberately out of scope:
324
+ # there is no mechanical source for it.
325
+ def shared_skills_naming_a_holder
326
+ holders = skill_holders
327
+ specialized = specialized_by
328
+ skill_sources.flat_map do |name, content|
329
+ owners = holders[name].to_a
330
+ next [] if owners.length < 2
331
+
332
+ # Who still READS this shared body: a holder with its own version reads that
333
+ # instead, so it stops being a victim — but its identity in the shared text
334
+ # keeps poisoning whoever is left. Identity comes from ALL holders; only the
335
+ # readers shrink as specializations land, so the finding clears when the last
336
+ # victim stops reading, never merely because the named holder moved out.
337
+ readers = owners - specialized[name].to_a
338
+ owners.flat_map do |owner|
339
+ victims = (readers - [owner]).sort
340
+ next [] if victims.empty?
341
+
342
+ identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
343
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
344
+ message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
345
+ "#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
346
+ "'#{owner}' if it needs that text, and remove it from the shared body.")
347
+ end
348
+ end
349
+ end
350
+ end
351
+
352
+ # D3 residue, the half `companions:` cannot prevent: a body that points at another
353
+ # catalog skill without declaring it (so the pair can still break apart), and a
354
+ # declared companion an agent is not allowed to load (so the pair breaks for THAT
355
+ # agent, silently, since the engine will not widen an allowlist on its own).
356
+ def broken_companions
357
+ names = skill_sources.keys
358
+ undeclared = skill_sources.flat_map do |name, content|
359
+ declared = declared_companions(content)
360
+ (names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
361
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
362
+ message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
363
+ "companion — the two can arrive apart, and half a recipe is worse than none. " \
364
+ "Add `companions: [#{other}]` to '#{name}'.")
365
+ end
366
+ end
367
+ undeclared + companions_outside_allowlists
368
+ end
369
+
370
+ # Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
371
+ # as one comma-separated String when the tolerant parser had to fall back.
372
+ def declared_companions(content)
373
+ raw = frontmatter_of(content)["companions"]
374
+ Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
375
+ end
376
+
377
+ def companions_outside_allowlists
378
+ declared = skill_sources.each_with_object({}) do |(name, content), acc|
379
+ list = declared_companions(content)
380
+ acc[name] = list unless list.empty?
381
+ end
382
+ return [] if declared.empty?
383
+
384
+ @profile_source.all.flat_map do |profile|
385
+ next [] if profile.skills.nil?
386
+
387
+ allowed = Array(profile.skills).map(&:to_s)
388
+ declared.flat_map do |name, companions|
389
+ next [] unless allowed.include?(name)
390
+
391
+ (companions - allowed).map do |missing|
392
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
393
+ message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
394
+ "the pair cannot travel together for this agent. Allow '#{missing}' too.")
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ # { skill name => [agent ids that have their OWN version] }.
401
+ def specialized_by
402
+ return {} unless @skill_store.respond_to?(:agents)
403
+
404
+ @skill_store.agents.each_with_object({}) do |agent, acc|
405
+ @skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
406
+ end
407
+ end
408
+
409
+ # { skill name => Set(agent ids that allow it explicitly) }. An agent with
410
+ # skills=nil allows everything and is not a "holder": it says nothing about which
411
+ # skills were meant to be shared.
412
+ def skill_holders
413
+ @profile_source.all.each_with_object({}) do |profile, acc|
414
+ next if profile.skills.nil?
415
+
416
+ Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
417
+ end
418
+ end
419
+
420
+ # An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
421
+ # calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
422
+ # short ones ignored — "store" appears in every retail skill ever written, and one
423
+ # false positive is enough for an operator to stop reading the doctor.
424
+ IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
425
+ prod staging demo test main default].freeze
426
+
427
+ def identity_terms(agent_id)
428
+ profile = @profile_source.fetch(agent_id)
429
+ meta = profile ? (profile.metadata || {}) : {}
430
+ raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
431
+ raw.flatten.compact.map { |t| t.to_s.strip }
432
+ .reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
433
+ .uniq
434
+ end
435
+
436
+ # Whole-word, case- and accent-insensitive — the same reading the trigger matcher
437
+ # uses, for the same reason: a substring hit inside a longer word is a false
438
+ # positive, and one of those is enough to lose the operator.
439
+ def mentions?(text, term)
440
+ needle = fold(term)
441
+ return false if needle.empty?
442
+
443
+ /(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
444
+ end
445
+
446
+ def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
447
+
448
+ def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
449
+
450
+ def platform_chat_rate_limit
451
+ value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
452
+ value.to_i.positive?
453
+ end
454
+
455
+ # A stored data-tool whose definition no longer builds is INVISIBLE at runtime: the
456
+ # overlay warns to stderr and drops it, so the agent simply stops seeing the tool.
457
+ # This check is that drop's only report. The one legacy case is auto-fixable: a
458
+ # flat param typed bare `array`, which used to mean "list of strings" — the fix
459
+ # writes that meaning down as `array:string` and changes nothing at runtime.
460
+ # A prompt file holding the Ruby #inspect of a Hash instead of markdown. The write
461
+ # path refuses this now, but a deployment corrupted BEFORE that guard keeps serving
462
+ # the mangled prompt on every turn, and nothing else would ever say so: the file is
463
+ # present, non-empty, and the agent answers — worse than a crash. Found on the pilot
464
+ # by an `insika refine` report, three weeks after the fact.
465
+ def check_prompt_files
466
+ return [] unless @agent_file_store
467
+
468
+ agents = @agent_file_store.agents
469
+ wrapped = agents.flat_map do |agent|
470
+ @agent_file_store.list(agent).filter_map do |name|
471
+ next unless wrapped_content?(@agent_file_store.read(agent, name))
472
+
473
+ Finding.new(check: "prompt-files", severity: :error, fix: nil,
474
+ message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
475
+ "the model receives `{\"content\" => …}` on one line, escapes and all. " \
476
+ "Recover the markdown from inside the wrapper and write it back.")
477
+ end
478
+ end
479
+ return wrapped if wrapped.any?
480
+
481
+ total = agents.sum { |a| @agent_file_store.list(a).length }
482
+ [ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
483
+ end
484
+
485
+ # Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
486
+ # prompt does not open with `{"…" =>`.
487
+ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
488
+
489
+ def check_data_tools
490
+ return [] unless @tool_store
491
+
492
+ broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
493
+ total = @tool_store.names.length
494
+ return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
495
+
496
+ broken
497
+ end
498
+
499
+ def broken_tool(raw)
500
+ Insika::ToolDefinition.from_h(raw)
501
+ nil
502
+ rescue Insika::ValidationError => e
503
+ name = raw.is_a?(Hash) ? raw["name"].to_s : ""
504
+ fix = array_sugar_fix(raw)
505
+ message = "tool '#{name}' is dropped from the catalog: #{e.message}"
506
+ # The autofix preserves the OLD behaviour, which is not always the INTENDED one:
507
+ # a list of objects has been reaching the model declared as a list of strings.
508
+ # Say so, so nobody reads a green doctor as "this tool is right".
509
+ message += " — the fix only writes down what it has been sending (a list of strings); " \
510
+ "if the API expects objects, paste the real JSON Schema in Studio instead" if fix
511
+ Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
512
+ end
513
+
514
+ # A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
515
+ # definition build — never a guess at a broken definition we don't understand.
516
+ def array_sugar_fix(raw)
517
+ params = raw.is_a?(Hash) ? raw["parameters"] : nil
518
+ return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
519
+
520
+ candidate = raw.merge("parameters" => params.map do |p|
521
+ p.is_a?(Hash) && p["type"].to_s == "array" ? p.merge("type" => "array:string") : p
522
+ end)
523
+ Insika::ToolDefinition.from_h(candidate)
524
+ -> { @tool_store.write(candidate) }
525
+ rescue Insika::ValidationError
526
+ nil
527
+ end
528
+
529
+ # -- helpers -------------------------------------------------------
530
+
531
+ def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
532
+
533
+ # A model to seed the platform default from: DEEPSEEK_MODEL env, else the first
534
+ # configured provider's default. nil when nothing to seed from.
535
+ def seed_model
536
+ Insika::Coercion.presence(@env["DEEPSEEK_MODEL"])
537
+ end
538
+ end
539
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module DSL
5
+ # The result of `Insika.agent("id") { … }`. Holds the generated Pack and knows
6
+ # how to run it — but stays gem-free until you actually chat/serve/introspect:
7
+ # the runtime (ruby_llm + the HTTP server) is required lazily inside Runtime.
8
+ #
9
+ # Every runtime path goes through the SAME config-over-code import: the pack is
10
+ # imported (create_agent + write_agent_file/skill/data_tool) into a StoredProfileSource,
11
+ # exactly like any other pack. That is what makes `#profile` identical to a
12
+ # hand-written equivalent pack (the parity spec) — there is only one path.
13
+ class Definition
14
+ attr_reader :pack, :runtime_options
15
+
16
+ def initialize(pack:, runtime: {})
17
+ @pack = pack
18
+ @runtime_options = runtime
19
+ end
20
+
21
+ def id = pack.config[:id].to_s
22
+
23
+ # The generated portable artifact — hand it to any PackImporter and you get
24
+ # this same agent. This is the DSL's real output.
25
+ def to_pack = @pack
26
+
27
+ # The AgentProfile the engine runs, read back from the store after import —
28
+ # so it is byte-for-byte what a hand-written equivalent pack produces.
29
+ def profile = runtime.profile
30
+
31
+ # One turn, in-process, returns the assistant's text. `session:` threads
32
+ # multi-turn memory (a session is created on first use); omit it for a
33
+ # stateless one-shot. Raises on a failed/cancelled turn.
34
+ def reply(message, session: nil, timeout: nil)
35
+ runtime.chat(message, session_id: session, timeout: timeout)
36
+ end
37
+ alias_method :chat, :reply
38
+
39
+ # Boot the control UI (/studio) + the drop-in API (/v1) — the "1 command"
40
+ # of the quickstart. Blocks (runs the reactor) until interrupted.
41
+ def serve(port: 9292, host: "localhost", token: nil, **opts)
42
+ runtime.serve(port: port, host: host, token: token, **opts)
43
+ end
44
+
45
+ # The live runtime graph (Executor + bus + stores) with the pack imported.
46
+ # Memoized: chat/serve/profile share one graph. Pulls in ruby_llm here.
47
+ def runtime
48
+ @runtime ||= begin
49
+ require_relative "runtime"
50
+ Runtime.new(self)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end