insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -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 +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -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 +362 -0
  22. data/docs/SKILLS.md +98 -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 +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LLM config v2 resolution (FOLLOWUP §10). 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,114 @@
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 is a follow-up; today the
18
+ # chain is resolved + surfaced (source/pinned) for telemetry.
19
+ ModelSelection = Data.define(:model, :provider, :source, :pinned, :params, :fallbacks) do
20
+ def initialize(model:, provider: nil, source: :platform_default, pinned: false,
21
+ params: {}, fallbacks: [])
22
+ super
23
+ end
24
+
25
+ def pinned? = pinned
26
+
27
+ # provider present => tell RubyLLM to trust the id (skip the registry lookup),
28
+ # matching the pre-v2 create_chat behavior.
29
+ def assume_model_exists? = !provider.nil?
30
+
31
+ # Applies the generation params to a RubyLLM chat (or any object exposing the
32
+ # same `with_*` fluent API). Pure and duck-typed: a `with_*` the target does
33
+ # not expose is skipped (a fake chat in specs need only implement the ones it
34
+ # asserts). RubyLLM raises on a nil arg, so only PRESENT params are applied.
35
+ # Returns the chat (the `with_*` calls mutate and return self).
36
+ def apply_params(chat)
37
+ p = params || {}
38
+ apply(chat, :with_temperature, p[:temperature]) if numeric?(p[:temperature])
39
+ apply_payload_params(chat, p)
40
+ apply_effort(chat, p[:thinking]) if present?(p[:thinking])
41
+ chat
42
+ end
43
+
44
+ private
45
+
46
+ def apply(chat, method, value)
47
+ chat.public_send(method, value) if chat.respond_to?(method)
48
+ end
49
+
50
+ # The params that ride RubyLLM's `with_params` (deep-merged OVER the provider
51
+ # payload, so they win over a provider default). ONE call on purpose:
52
+ # `with_params` REPLACES the whole hash — a second call would silently drop
53
+ # the first one's keys.
54
+ #
55
+ # max_tokens goes here because the gem has NO with_max_* setter: the chat's
56
+ # fluent API covers temperature/thinking only, and Anthropic is the only
57
+ # provider that emits a cap at all (from the model registry, 4096 by default).
58
+ # `max_tokens` is the wire key for Anthropic and for every OpenAI-compatible
59
+ # provider (DeepSeek included); OpenAI's o-series wants
60
+ # `max_completion_tokens` instead — that per-provider mapping is not modelled
61
+ # yet, and a raw provider key is not authorable (the profile whitelists
62
+ # temperature/max_tokens/thinking).
63
+ def apply_payload_params(chat, p)
64
+ return unless chat.respond_to?(:with_params)
65
+
66
+ payload = {}
67
+ payload[:max_tokens] = p[:max_tokens] if numeric?(p[:max_tokens])
68
+ toggle = thinking_toggle(p[:thinking])
69
+ payload[:thinking] = { type: toggle } if toggle
70
+ chat.with_params(**payload) unless payload.empty?
71
+ end
72
+
73
+ # The resolved reasoning control (§10, 4-layer). Two axes folded into one field:
74
+ # off -> reasoning DISABLED (thinking:{type:disabled})
75
+ # on -> reasoning ENABLED, provider-default effort
76
+ # low|medium|high -> reasoning enabled at that effort (reasoning_effort)
77
+ # Blank/nil never reaches here (apply_params guards on present?), so no config
78
+ # anywhere = the provider's own default.
79
+ #
80
+ # ruby_llm 1.16's with_thinking only EMITS reasoning_effort and cannot toggle
81
+ # on/off; the on/off ride on with_params (deep-merged over the payload). The
82
+ # thinking:{type:} shape is DeepSeek's OpenAI-compat contract — gated to it
83
+ # (nil provider = the platform default, DeepSeek here); other providers get
84
+ # effort-only until their toggle wire is mapped (e.g. Anthropic output_config).
85
+ def thinking_toggle(value)
86
+ return nil unless present?(value) && reasoning_toggle_supported?
87
+
88
+ case value.to_s
89
+ when "off" then "disabled"
90
+ when "on" then "enabled"
91
+ end
92
+ end
93
+
94
+ def apply_effort(chat, value)
95
+ return unless %w[low medium high].include?(value.to_s)
96
+
97
+ chat.with_thinking(effort: value.to_sym) if chat.respond_to?(:with_thinking)
98
+ end
99
+
100
+ def reasoning_toggle_supported?
101
+ provider.nil? || provider.to_sym == :deepseek
102
+ end
103
+
104
+ def numeric?(v) = v.is_a?(Numeric)
105
+ def present?(v) = Insika::Coercion.present?(v)
106
+ end
107
+
108
+ # The selectable reasoning values (§10, 4-layer). Blank/absent = inherit the
109
+ # broader layer; these are the explicit choices. Shared with the Studio forms.
110
+ # Defined on the class OUTSIDE the Data.define block on purpose: a constant
111
+ # assigned inside the block would land in the enclosing lexical scope (Insika),
112
+ # not on ModelSelection.
113
+ ModelSelection::THINKING_LEVELS = %w[off on low medium high].freeze
114
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LLM-first onboarding surface (item 20 / §5.6). 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 (FOLLOWUP / techspec / 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
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Durable record of one pending OUTBOUND reply (RFC-0011 §6.5). A Shape B
8
+ # channel answers out of band — the turn ends and the reply is POSTed to the
9
+ # platform (or, for a relay, to the consumer's own callback) afterwards — so
10
+ # "the answer exists but the recipient is not on this connection" is exactly
11
+ # the problem RFC-0010 Phase 2 already solved for async delegation. This store
12
+ # is DelegationStore's shape with a different recipient, deliberately: a second
13
+ # invention here would be a second thing to get wrong.
14
+ #
15
+ # Lifecycle (never backwards):
16
+ # pending the turn committed a reply; nobody has claimed it yet. This is
17
+ # the ONLY status the boot sweep re-drives.
18
+ # delivering claimed. The claim happens BEFORE the HTTP call, so a crash
19
+ # here loses the delivery rather than duplicating it —
20
+ # at-most-once, the same honest scope as the delegation path.
21
+ # delivered the recipient answered 2xx.
22
+ # failed the bounded retry ran out. Terminal, and NOT re-driven at boot:
23
+ # a third party that refused N times is an operator problem, not
24
+ # something to replay forever.
25
+ #
26
+ # `failed` and `delivering` are a deliberate widening of the RFC's two-status
27
+ # sketch (`pending -> delivered`): without them a crashed claim and an
28
+ # exhausted retry are indistinguishable from a fresh record, and the sweep
29
+ # would redeliver both.
30
+ #
31
+ # Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
32
+ # like every other domain store.
33
+ class OutboxStore
34
+ SCOPE = "outbox"
35
+ KEY_PREFIX = "outbox:"
36
+
37
+ STATUSES = %i[pending delivering delivered failed].freeze
38
+
39
+ Delivery = Data.define(
40
+ :id, :channel, :to, :task_id, :session_id, :payload,
41
+ :status, :attempts, :last_error, :created_at, :updated_at
42
+ )
43
+
44
+ def initialize(store:)
45
+ @store = store
46
+ end
47
+
48
+ # -> Delivery (:pending). `payload` is the body the channel will send; it is
49
+ # DATA (string keys, JSON types) and the store never interprets it.
50
+ def create(channel:, to:, task_id:, session_id:, payload:, id: SecureRandom.uuid)
51
+ record = {
52
+ "id" => id.to_s,
53
+ "channel" => channel.to_s,
54
+ "to" => to.to_s,
55
+ "task_id" => task_id&.to_s,
56
+ "session_id" => session_id&.to_s,
57
+ "payload" => payload,
58
+ "status" => "pending",
59
+ "attempts" => 0,
60
+ "last_error" => nil,
61
+ "created_at" => timestamp,
62
+ "updated_at" => timestamp
63
+ }
64
+ @store.set(SCOPE, key_for(id), record)
65
+ to_delivery(record)
66
+ end
67
+
68
+ # -> Delivery | nil
69
+ def find(id)
70
+ record = @store.get(SCOPE, key_for(id))
71
+ record && to_delivery(record)
72
+ end
73
+
74
+ # -> [Delivery] still waiting for a first claim (boot sweep). Deliberately NOT
75
+ # `delivering`: that one was claimed by a process that then died, and whether
76
+ # its POST landed is unknowable — replaying it is the duplicate the claim
77
+ # exists to prevent.
78
+ def pending
79
+ scan.select { |d| d.status == :pending }
80
+ end
81
+
82
+ # pending -> delivering, ATOMICALLY — across processes, not just fibers: the
83
+ # read-check-write rides Store#transaction, so two workers claiming the same
84
+ # record serialize on the backend's lock and only one sees :pending. Returns
85
+ # true only for the caller that won the transition; that caller (and only it)
86
+ # makes the HTTP call, so delivery is at-most-once even if the terminal hook
87
+ # and the boot sweep both fire.
88
+ def claim(id)
89
+ @store.transaction do
90
+ record = @store.get(SCOPE, key_for(id))
91
+ next false unless record && record["status"] == "pending"
92
+
93
+ record["status"] = "delivering"
94
+ record["updated_at"] = timestamp
95
+ @store.set(SCOPE, key_for(id), record)
96
+ true
97
+ end
98
+ end
99
+
100
+ # One attempt happened and did not succeed. Keeps the record :delivering (the
101
+ # in-process retry loop owns it) and writes down why, so an operator reading
102
+ # the store sees the third party's answer and not just a counter.
103
+ def record_attempt(id, error: nil)
104
+ record = fetch!(id)
105
+ record["attempts"] = record["attempts"].to_i + 1
106
+ record["last_error"] = error&.to_s
107
+ touch(id, record)
108
+ end
109
+
110
+ # -> Delivery (:delivered). Idempotent: an already-delivered record is
111
+ # returned unchanged rather than re-marked.
112
+ def mark_delivered(id)
113
+ record = fetch!(id)
114
+ return to_delivery(record) if record["status"] == "delivered"
115
+
116
+ record["status"] = "delivered"
117
+ record["attempts"] = record["attempts"].to_i + 1
118
+ record["last_error"] = nil
119
+ touch(id, record)
120
+ end
121
+
122
+ # -> Delivery (:failed). The retry budget is spent; nothing re-drives this.
123
+ def mark_failed(id, error: nil)
124
+ record = fetch!(id)
125
+ record["status"] = "failed"
126
+ record["last_error"] = error&.to_s if error
127
+ touch(id, record)
128
+ end
129
+
130
+ private
131
+
132
+ def scan
133
+ return enum_for(:scan) unless block_given?
134
+
135
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
136
+ record = @store.get(SCOPE, key)
137
+ yield to_delivery(record) if record
138
+ end
139
+ end
140
+
141
+ def fetch!(id)
142
+ @store.get(SCOPE, key_for(id)) ||
143
+ (raise Insika::NotFoundError, "outbox delivery not found: #{id}")
144
+ end
145
+
146
+ def touch(id, record)
147
+ record["updated_at"] = timestamp
148
+ @store.set(SCOPE, key_for(id), record)
149
+ to_delivery(record)
150
+ end
151
+
152
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
153
+
154
+ def to_delivery(record)
155
+ Delivery.new(
156
+ id: record["id"], channel: record["channel"], to: record["to"],
157
+ task_id: record["task_id"], session_id: record["session_id"],
158
+ payload: record["payload"], status: record["status"].to_sym,
159
+ attempts: record["attempts"].to_i, last_error: record["last_error"],
160
+ created_at: record["created_at"], updated_at: record["updated_at"]
161
+ )
162
+ end
163
+
164
+ def timestamp = Time.now.utc.iso8601
165
+ end
166
+ end