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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Governance of WHICH models an agent may use (`profile.model_policy`).
5
+ # Cheap allowlist: exact refs + wildcard-by-provider — evaluated against the
6
+ # RESOLVED (model, provider) at turn start (ModelResolver), so a chat override
7
+ # can never pin a model outside the agent's fence.
8
+ #
9
+ # A ref is a String in one of three shapes:
10
+ # "provider/model" -> matches that exact provider AND model
11
+ # "provider/*" -> matches ANY model of that provider (wildcard)
12
+ # "model" -> matches that model under ANY provider (provider-agnostic)
13
+ #
14
+ # `allow` semantics follow the ONE-allowlist rule of the project:
15
+ # nil = NO policy (every model allowed — parity, the common case)
16
+ # [] = NOTHING allowed (deny-all; an agent so configured cannot resolve a model)
17
+ # [refs] = the model must match at least one ref
18
+ module ModelPolicy
19
+ module_function
20
+
21
+ # policy: `profile.model_policy` — a Hash `{ "allow" => [refs] }` | nil.
22
+ # nil policy OR a nil/absent `allow` list = allowed (no fence). -> bool.
23
+ def allowed?(policy, model:, provider:)
24
+ allow = allow_list(policy)
25
+ return true if allow.nil? # no fence
26
+
27
+ model = model.to_s
28
+ provider = provider.to_s
29
+ allow.any? { |ref| match?(ref.to_s, model, provider) }
30
+ end
31
+
32
+ # Extracts the allow list (string|symbol key), nil when there is no fence.
33
+ def allow_list(policy)
34
+ return nil if policy.nil?
35
+
36
+ list = policy["allow"] || policy[:allow]
37
+ list.nil? ? nil : Array(list)
38
+ end
39
+
40
+ def match?(ref, model, provider)
41
+ if ref.include?("/")
42
+ ref_provider, ref_model = ref.split("/", 2)
43
+ return false unless ref_provider == provider
44
+
45
+ ref_model == "*" || ref_model == model
46
+ else
47
+ # provider-agnostic: match the model under any provider.
48
+ ref == model
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LLM config v2 resolution. Turns the three config layers into a
5
+ # single ModelSelection at turn start:
6
+ #
7
+ # Chat (per-session pin) > Agent (profile.model) > Platform default (Settings)
8
+ #
9
+ # Also enforces the agent's `model_policy` on the RESOLVED model (a chat pin can
10
+ # never escape the fence) and resolves the fallback chain + selection source.
11
+ #
12
+ # Pure: no ruby_llm, no store writes. `settings_store` is optional — nil means
13
+ # "no platform layer" (an agent WITHOUT a model then fails high), preserving the
14
+ # pre-v2 behavior for the many call sites that build an Executor without one.
15
+ class ModelResolver
16
+ # Reserved, collision-safe slot for the per-session model pin inside
17
+ # `session.vars` (namespaced so it never renders in <request_context> — the
18
+ # Request provider skips "__"-prefixed vars).
19
+ SESSION_SLOT = "__llm__"
20
+
21
+ def initialize(settings_store: nil)
22
+ @settings_store = settings_store
23
+ end
24
+
25
+ # The layer that won resolution, before policy/fallback processing.
26
+ # `source` is where the model came from (:chat/:agent/:platform_default).
27
+ Choice = Data.define(:model, :provider, :source, :pinned)
28
+ private_constant :Choice
29
+
30
+ # profile: AgentProfile; session: SessionStore::Session | nil.
31
+ # -> ModelSelection. Raises PolicyDenied (model outside the agent's fence) or
32
+ # Error (no model resolvable at any layer).
33
+ def resolve(profile:, session: nil)
34
+ settings = platform_settings
35
+ choice = pick(profile, session, settings)
36
+
37
+ if blank?(choice.model)
38
+ raise Insika::Error,
39
+ "no model resolved for agent '#{profile.id}': set the agent model or a platform default_model (Settings)"
40
+ end
41
+
42
+ provider = choice.provider&.to_sym
43
+ enforce_policy!(profile, choice.model, provider)
44
+
45
+ ModelSelection.new(
46
+ model: choice.model, provider: provider, source: choice.source, pinned: choice.pinned,
47
+ params: resolve_params(profile, session, settings, choice.model, provider),
48
+ fallbacks: choice.pinned ? [] : resolve_fallbacks(profile, settings, choice.model, provider)
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ # Chat pin > agent model > platform default.
55
+ def pick(profile, session, settings)
56
+ pin = session_pin(session)
57
+ if pin && !blank?(pin[:model])
58
+ Choice.new(model: pin[:model], provider: pin[:provider], source: :chat, pinned: true)
59
+ elsif !blank?(profile.model)
60
+ Choice.new(model: profile.model, provider: profile.provider, source: :agent, pinned: false)
61
+ else
62
+ Choice.new(model: settings["default_model"], provider: settings["default_provider"],
63
+ source: :platform_default, pinned: false)
64
+ end
65
+ end
66
+
67
+ # Per-session pin from vars["__llm__"] = { "model" =>, "provider" => }.
68
+ # SessionStore deep_stringifies vars on write, so the slot is always
69
+ # string-keyed. -> { model:, provider: } | nil.
70
+ def session_pin(session)
71
+ slot = session&.vars&.dig(SESSION_SLOT)
72
+ return nil unless slot.is_a?(Hash)
73
+
74
+ { model: slot["model"], provider: slot["provider"] }
75
+ end
76
+
77
+ def enforce_policy!(profile, model, provider)
78
+ policy = model_policy(profile)
79
+ return if ModelPolicy.allowed?(policy, model: model, provider: provider)
80
+
81
+ raise Insika::PolicyDenied.new(
82
+ policy: :model_policy,
83
+ reason: "model '#{ref(model, provider)}' not allowed for agent '#{profile.id}' " \
84
+ "(allow: #{ModelPolicy.allow_list(policy).inspect})"
85
+ )
86
+ end
87
+
88
+ # Platform fallback chain (Settings["fallback_models"]) minus the primary,
89
+ # filtered by the agent's model_policy. Each entry: "provider/model" | "model".
90
+ def resolve_fallbacks(profile, settings, primary_model, primary_provider)
91
+ policy = model_policy(profile)
92
+ Array(settings["fallback_models"]).filter_map do |entry|
93
+ model, provider = parse_ref(entry)
94
+ next if model.nil?
95
+ next if model == primary_model && provider == primary_provider # dedupe the primary
96
+ next unless ModelPolicy.allowed?(policy, model: model, provider: provider)
97
+
98
+ { model: model, provider: provider }
99
+ end
100
+ end
101
+
102
+ # "provider/model" -> [model, :provider]; "model" -> [model, nil].
103
+ def parse_ref(entry)
104
+ s = entry.to_s.strip
105
+ return [nil, nil] if s.empty?
106
+
107
+ if s.include?("/")
108
+ provider, model = s.split("/", 2)
109
+ [model, presence(provider)&.to_sym]
110
+ else
111
+ [s, nil]
112
+ end
113
+ end
114
+
115
+ def platform_settings
116
+ @settings_store ? @settings_store.get : {}
117
+ end
118
+
119
+ # AgentProfile always carries `params` (a string-keyed Hash, {} when unset)
120
+ # and `model_policy` (nil = no fence) — no feature-detection needed.
121
+ def profile_params(profile) = profile.params
122
+ def model_policy(profile) = profile.model_policy
123
+
124
+ # Generation params: the agent's temperature/max_tokens plus the reasoning
125
+ # control (`thinking`) resolved across the 4 layers. A resolved nil means
126
+ # "nothing set anywhere" -> drop the key so the provider default stands.
127
+ def resolve_params(profile, session, settings, model, provider)
128
+ params = normalize_params(profile_params(profile))
129
+ thinking = resolve_thinking(profile, session, settings, model, provider)
130
+ thinking.nil? ? params.tap { |p| p.delete(:thinking) } : params.merge(thinking: thinking)
131
+ end
132
+
133
+ # First non-blank of Chat > Agent > Model-default > Global (most specific wins).
134
+ def resolve_thinking(profile, session, settings, model, provider)
135
+ [
136
+ chat_thinking(session),
137
+ presence(profile.params["thinking"]),
138
+ model_thinking(settings, model, provider),
139
+ presence(settings["thinking"])
140
+ ].find { |v| !blank?(v) }
141
+ end
142
+
143
+ # Per-chat override: the `thinking` slot in the session pin (vars["__llm__"]),
144
+ # independent of whether the chat also pinned a model.
145
+ def chat_thinking(session)
146
+ slot = session&.vars&.dig(SESSION_SLOT)
147
+ slot.is_a?(Hash) ? presence(slot["thinking"]) : nil
148
+ end
149
+
150
+ # Per-model default: settings["model_params"][ref]["thinking"], matched by the
151
+ # full "provider/model" ref first, then the bare model id.
152
+ def model_thinking(settings, model, provider)
153
+ map = settings["model_params"]
154
+ return nil unless map.is_a?(Hash)
155
+
156
+ ref = provider ? "#{provider}/#{model}" : nil
157
+ entry = (ref && map[ref]) || map[model.to_s]
158
+ entry.is_a?(Hash) ? presence(entry["thinking"]) : nil
159
+ end
160
+
161
+ # Coerces authored params (string|symbol keys from the JSON round-trip) into
162
+ # the symbol shape ModelSelection#apply_params consumes.
163
+ def normalize_params(params)
164
+ out = {}
165
+ params.each do |k, v|
166
+ key = k.to_sym
167
+ out[key] = v if %i[temperature max_tokens thinking].include?(key)
168
+ end
169
+ out
170
+ end
171
+
172
+ def ref(model, provider) = provider ? "#{provider}/#{model}" : model.to_s
173
+ def blank?(v) = Insika::Coercion.blank?(v)
174
+ def presence(v) = Insika::Coercion.presence(v)
175
+ end
176
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The RESOLVED model decision for a turn (ModelResolver output). Immutable
5
+ # snapshot carried on the TurnState, applied to the RubyLLM chat at stage 5 and
6
+ # surfaced in the turn's usage for telemetry/billing.
7
+ #
8
+ # Fields:
9
+ # model -> the resolved model id (String) | nil (RubyLLM's own default)
10
+ # provider -> Symbol | nil (nil => RubyLLM infers from the model registry)
11
+ # source -> :chat | :agent | :platform_default (WHERE the model came from)
12
+ # pinned -> true when the model is a USER pin (per-chat override). A pin
13
+ # "fails HIGH": no silent fallback (OpenClaw semantics). An agent
14
+ # model / platform default is NOT pinned -> fallbacks apply.
15
+ # params -> generation params (Hash of symbols: temperature/max_tokens/thinking)
16
+ # fallbacks -> ordered [{ model:, provider: }] to try when NOT pinned. The
17
+ # mid-turn ROTATION across this chain (plus the profile's own
18
+ # reliability["fallback"] refs) is WS3's Reliability coordinator;
19
+ # here the chain is resolved + surfaced (source/pinned) for it.
20
+ ModelSelection = Data.define(:model, :provider, :source, :pinned, :params, :fallbacks) do
21
+ def initialize(model:, provider: nil, source: :platform_default, pinned: false,
22
+ params: {}, fallbacks: [])
23
+ super
24
+ end
25
+
26
+ def pinned? = pinned
27
+
28
+ # provider present => tell RubyLLM to trust the id (skip the registry lookup),
29
+ # matching the pre-v2 create_chat behavior.
30
+ def assume_model_exists? = !provider.nil?
31
+
32
+ # Applies the generation params to a RubyLLM chat (or any object exposing the
33
+ # same `with_*` fluent API). Pure and duck-typed: a `with_*` the target does
34
+ # not expose is skipped (a fake chat in specs need only implement the ones it
35
+ # asserts). RubyLLM raises on a nil arg, so only PRESENT params are applied.
36
+ # Returns the chat (the `with_*` calls mutate and return self).
37
+ def apply_params(chat)
38
+ p = params || {}
39
+ apply(chat, :with_temperature, p[:temperature]) if numeric?(p[:temperature])
40
+ apply_payload_params(chat, p)
41
+ apply_effort(chat, p[:thinking]) if present?(p[:thinking])
42
+ chat
43
+ end
44
+
45
+ private
46
+
47
+ def apply(chat, method, value)
48
+ chat.public_send(method, value) if chat.respond_to?(method)
49
+ end
50
+
51
+ # The params that ride RubyLLM's `with_params` (deep-merged OVER the provider
52
+ # payload, so they win over a provider default). ONE call on purpose:
53
+ # `with_params` REPLACES the whole hash — a second call would silently drop
54
+ # the first one's keys.
55
+ #
56
+ # max_tokens goes here because the gem has NO with_max_* setter: the chat's
57
+ # fluent API covers temperature/thinking only, and Anthropic is the only
58
+ # provider that emits a cap at all (from the model registry, 4096 by default).
59
+ # `max_tokens` is the wire key for Anthropic and for every OpenAI-compatible
60
+ # provider (DeepSeek included); OpenAI's o-series wants
61
+ # `max_completion_tokens` instead — that per-provider mapping is not modelled
62
+ # yet, and a raw provider key is not authorable (the profile whitelists
63
+ # temperature/max_tokens/thinking).
64
+ def apply_payload_params(chat, p)
65
+ return unless chat.respond_to?(:with_params)
66
+
67
+ payload = {}
68
+ payload[:max_tokens] = p[:max_tokens] if numeric?(p[:max_tokens])
69
+ toggle = thinking_toggle(p[:thinking])
70
+ payload[:thinking] = { type: toggle } if toggle
71
+ chat.with_params(**payload) unless payload.empty?
72
+ end
73
+
74
+ # The resolved reasoning control (4-layer). Two axes folded into one field:
75
+ # off -> reasoning DISABLED (thinking:{type:disabled})
76
+ # on -> reasoning ENABLED, provider-default effort
77
+ # low|medium|high -> reasoning enabled at that effort (reasoning_effort)
78
+ # Blank/nil never reaches here (apply_params guards on present?), so no config
79
+ # anywhere = the provider's own default.
80
+ #
81
+ # ruby_llm 1.16's with_thinking only EMITS reasoning_effort and cannot toggle
82
+ # on/off; the on/off ride on with_params (deep-merged over the payload). The
83
+ # thinking:{type:} shape is DeepSeek's OpenAI-compat contract — gated to it
84
+ # (nil provider = the platform default, DeepSeek here); other providers get
85
+ # effort-only until their toggle wire is mapped (e.g. Anthropic output_config).
86
+ def thinking_toggle(value)
87
+ return nil unless present?(value) && reasoning_toggle_supported?
88
+
89
+ case value.to_s
90
+ when "off" then "disabled"
91
+ when "on" then "enabled"
92
+ end
93
+ end
94
+
95
+ def apply_effort(chat, value)
96
+ return unless %w[low medium high].include?(value.to_s)
97
+
98
+ chat.with_thinking(effort: value.to_sym) if chat.respond_to?(:with_thinking)
99
+ end
100
+
101
+ def reasoning_toggle_supported?
102
+ provider.nil? || provider.to_sym == :deepseek
103
+ end
104
+
105
+ def numeric?(v) = v.is_a?(Numeric)
106
+ def present?(v) = Insika::Coercion.present?(v)
107
+ end
108
+
109
+ # The selectable reasoning values (4-layer). Blank/absent = inherit the
110
+ # broader layer; these are the explicit choices. Shared with the Studio forms.
111
+ # Defined on the class OUTSIDE the Data.define block on purpose: a constant
112
+ # assigned inside the block would land in the enclosing lexical scope (Insika),
113
+ # not on ModelSelection.
114
+ ModelSelection::THINKING_LEVELS = %w[off on low medium high].freeze
115
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LLM-first onboarding surface. The "Flue trick": the insika
5
+ # serves, from itself, a `start.md` addressed to the DEVELOPER'S OWN coding agent
6
+ # ("Read <base>/start.md then help me build my first agent") plus a machine-readable
7
+ # `/models.json` and the public docs mirrored as raw markdown. It is `rails new`
8
+ # reimplemented as a prompt, with the generator being the coding agent the developer
9
+ # already has.
10
+ #
11
+ # Pure and data-defined: everything it serves comes from injected sources — a
12
+ # `start.md` TEMPLATE file, a NAME=>path map of public docs, and (optionally) the
13
+ # SettingsStore / LLMProviderStore / the served agents. It only READS (no writes, no
14
+ # RubyLLM, no Executor), so the transport can call it under the constitutional rule.
15
+ # Secrets never leak: `models_json` reads the MASKED provider view and omits base
16
+ # urls/keys entirely — only slugs and model ids, which is all a coding agent needs to
17
+ # write a correct `model`/`provider` line.
18
+ #
19
+ # Injection (all optional but `template_path`/`docs`):
20
+ # template_path -> the start.md source (a real file, so it stays reviewable/raw)
21
+ # docs -> { "slug" => "/abs/path.md", … } served at GET /docs/:slug.md.
22
+ # An explicit ALLOWLIST — the gitignored internal docs are never
23
+ # in it, and there is no filesystem traversal (only map keys).
24
+ # settings_store -> platform default_model / fallbacks / thinking (nil = omit)
25
+ # provider_store -> configured providers + their model ids, MASKED (nil = omit)
26
+ # agents -> callable returning [{ id:, model:, provider:, description: }]
27
+ # for the agents already served here (nil = none)
28
+ class Onboarding
29
+ # Bumped when the models.json SHAPE changes (its own contract, independent of the
30
+ # settings schema). A consuming coding agent can branch on it.
31
+ MODELS_SCHEMA_VERSION = 1
32
+
33
+ # The PUBLIC docs allowlist, repo-relative: slug => path. Explicit on purpose —
34
+ # the gitignored internal docs (/ TRANSLATION-TRACKER / …)
35
+ # are NEVER here, so /docs can only ever serve OSS material. Kept in sync with the
36
+ # tracked `.md` prose (README + docs/*.md).
37
+ PUBLIC_DOCS = {
38
+ "readme" => "README.md",
39
+ "why" => "docs/WHY.md",
40
+ "agents" => "docs/AGENTS.md",
41
+ "tools" => "docs/TOOLS.md",
42
+ "skills" => "docs/SKILLS.md",
43
+ "context" => "docs/CONTEXT.md",
44
+ "workflows" => "docs/WORKFLOWS.md",
45
+ "channels" => "docs/CHANNELS.md",
46
+ "plugins" => "docs/PLUGINS.md",
47
+ "security" => "docs/SECURITY.md",
48
+ "architecture" => "docs/ARCHITECTURE.md",
49
+ "running-local" => "docs/RUNNING-LOCAL.md",
50
+ "deploy" => "docs/DEPLOY.md",
51
+ "embedding" => "docs/EMBEDDING.md",
52
+ "sandbox" => "docs/SANDBOX.md",
53
+ "benchmark" => "docs/BENCHMARK.md",
54
+ "observability" => "docs/OBSERVABILITY.md",
55
+ "evals" => "docs/EVALS.md",
56
+ "refinement" => "docs/REFINEMENT.md",
57
+ "loadtest" => "docs/LOADTEST.md",
58
+ "releasing" => "docs/RELEASING.md"
59
+ }.freeze
60
+
61
+ # Repo-relative path to the start.md template.
62
+ TEMPLATE = "docs/onboarding/start.md"
63
+
64
+ # Builds the standard onboarding surface rooted at `root` (the repo/gem root),
65
+ # wiring the PUBLIC_DOCS allowlist + start.md template. The three composition roots
66
+ # (minimal wiring, DSL serve, deployment) pass their own stores/agents on top. A
67
+ # doc whose file is absent (a slimmed-down gem) is simply dropped — never a boot
68
+ # failure.
69
+ def self.standard(root:, settings_store: nil, provider_store: nil, agents: nil)
70
+ docs = PUBLIC_DOCS.each_with_object({}) do |(slug, rel), acc|
71
+ path = File.join(root, rel)
72
+ acc[slug] = path if File.file?(path)
73
+ end
74
+ new(template_path: File.join(root, TEMPLATE), docs: docs,
75
+ settings_store: settings_store, provider_store: provider_store, agents: agents)
76
+ end
77
+
78
+ def initialize(template_path:, docs: {}, settings_store: nil, provider_store: nil, agents: nil)
79
+ @template_path = template_path
80
+ @docs = docs || {}
81
+ @settings_store = settings_store
82
+ @provider_store = provider_store
83
+ @agents = agents # callable -> [Hash] | nil
84
+ end
85
+
86
+ # The onboarding skill (raw markdown), with the live base url interpolated so the
87
+ # coding agent knows where to fetch the models list and docs. Read on each request
88
+ # (the file is small and this surface is low-traffic) — editing start.md needs no
89
+ # restart.
90
+ def start_md(base_url:)
91
+ base = normalize_base(base_url)
92
+ substitute(File.read(@template_path), base)
93
+ end
94
+
95
+ # Machine-readable model catalog. Everything a coding agent needs to write a valid
96
+ # `model`/`provider` line, and nothing secret. Sources that are absent (nil store)
97
+ # simply drop their key — a fresh DSL serve still returns a coherent document
98
+ # (served agents + thinking levels + whatever default is set).
99
+ def models_json(base_url:)
100
+ base = normalize_base(base_url)
101
+ settings = @settings_store&.get || {}
102
+ {
103
+ schema_version: MODELS_SCHEMA_VERSION,
104
+ base_url: base,
105
+ responses_url: "#{base}/v1/responses",
106
+ default: default_model(settings),
107
+ fallbacks: fallback_models(settings),
108
+ utility_model: presence(settings["utility_model"]),
109
+ thinking_levels: ModelSelection::THINKING_LEVELS,
110
+ providers: providers,
111
+ agents: served_agents
112
+ }.compact
113
+ end
114
+
115
+ # The public docs index (name + title + fetchable raw-markdown url). Drives
116
+ # discovery: a coding agent lists this, then GETs the ones it needs.
117
+ def docs_index(base_url:)
118
+ base = normalize_base(base_url)
119
+ @docs.keys.sort.map do |slug|
120
+ { name: slug, title: doc_title(slug), url: "#{base}/docs/#{slug}.md" }
121
+ end
122
+ end
123
+
124
+ # Raw markdown for one public doc, by slug. nil = unknown slug (the transport 404s).
125
+ # No path traversal is possible: `slug` must be a KEY of the injected allowlist.
126
+ #
127
+ # The Jekyll frontmatter the docs site needs (title/parent/nav_order — the same
128
+ # files ARE the site's pages) is STRIPPED here: a coding agent asked for the prose,
129
+ # not for sidebar metadata, and this keeps the response byte-identical to what it
130
+ # was before the site existed.
131
+ def doc(slug)
132
+ read_doc(slug)&.sub(FRONTMATTER, "")
133
+ end
134
+
135
+ private
136
+
137
+ # Leading YAML block, plus the blank line after it, so the body still starts at
138
+ # its `# Heading`. Same shape SkillCatalog accepts.
139
+ FRONTMATTER = /\A---\s*\n(.*?)\n---\s*\n+/m
140
+ private_constant :FRONTMATTER
141
+
142
+ # Whole file, frontmatter included. nil = unknown slug or a file that vanished.
143
+ def read_doc(slug)
144
+ path = @docs[slug.to_s]
145
+ return nil if path.nil? || !File.file?(path)
146
+
147
+ File.read(path)
148
+ end
149
+
150
+ def substitute(text, base)
151
+ text
152
+ .gsub("{{BASE_URL}}", base)
153
+ .gsub("{{MODELS_URL}}", "#{base}/models.json")
154
+ .gsub("{{DOCS_URL}}", "#{base}/docs")
155
+ end
156
+
157
+ # default_model/default_provider -> { model:, provider: } | nil (nothing set).
158
+ def default_model(settings)
159
+ model = presence(settings["default_model"])
160
+ return nil if model.nil?
161
+
162
+ { model: model, provider: presence(settings["default_provider"]) }.compact
163
+ end
164
+
165
+ # Platform fallback chain as ["provider/model" | "model", …]; blank entries dropped.
166
+ def fallback_models(settings)
167
+ Array(settings["fallback_models"]).filter_map { |entry| presence(entry) }
168
+ end
169
+
170
+ # Providers a coding agent can target, from the MASKED store view: slug + the
171
+ # model ids. Deliberately NO base_url / auth / key — model ids and slugs are all
172
+ # that a `model`/`provider` line needs, and nothing here is a secret.
173
+ def providers
174
+ return nil if @provider_store.nil?
175
+
176
+ @provider_store.all.map do |rec|
177
+ { slug: rec["api"].to_s, models: Array(rec["models"]).map(&:to_s) }
178
+ end
179
+ end
180
+
181
+ # Agents already served by THIS insika — over the drop-in API the `model` field
182
+ # is the agent id, so this is the list of ids a client can call right now.
183
+ def served_agents
184
+ list = @agents.respond_to?(:call) ? Array(@agents.call) : nil
185
+ return nil if list.nil? || list.empty?
186
+
187
+ list.map do |a|
188
+ { id: a[:id].to_s, model: presence(a[:model]), provider: presence(a[:provider]),
189
+ description: presence(a[:description]) }.compact
190
+ end
191
+ end
192
+
193
+ # Frontmatter `title` (what the site's sidebar shows — one source for both
194
+ # surfaces), else the first markdown heading, else the humanized slug.
195
+ def doc_title(slug)
196
+ raw = read_doc(slug)
197
+ front = raw&.match(FRONTMATTER)
198
+ title = front && presence(Frontmatter.parse(front[1])["title"])
199
+ return title if title
200
+
201
+ heading = raw&.lines&.find { |l| l.start_with?("# ") }
202
+ heading ? heading.sub(/\A#\s*/, "").strip : slug.tr("-_", " ").capitalize
203
+ end
204
+
205
+ def normalize_base(base_url) = base_url.to_s.sub(%r{/+\z}, "")
206
+ def presence(value) = Insika::Coercion.presence(value)
207
+ end
208
+ end