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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insika
6
+ # TOLERANT frontmatter parser (the YAML `--- ... ---` block of a SKILL.md).
7
+ # The convention is YAML, but real packs carry PROSE in `description` — with `: `
8
+ # (colon + space), quotes, parentheses — which STRICT YAML rejects
9
+ # ("mapping values are not allowed in this context"). The OpenClaw gateway
10
+ # tolerates it; the insika has to tolerate it too (the same pack must hold).
11
+ #
12
+ # Strategy: try YAML (respects quoted / multi-line / lists); if the YAML
13
+ # fails OR doesn't yield a Hash, fall back to a LINE-BY-LINE parse that splits on
14
+ # the FIRST `:` and treats the rest as a raw string — recovers name/description
15
+ # even with `: ` in the middle of the value. -> Hash with String keys. NEVER raises.
16
+ module Frontmatter
17
+ module_function
18
+
19
+ def parse(text)
20
+ loaded = begin
21
+ YAML.safe_load(text.to_s)
22
+ rescue Psych::SyntaxError
23
+ nil
24
+ end
25
+ loaded.is_a?(Hash) ? stringify(loaded) : lenient(text)
26
+ end
27
+
28
+ # Split on the first `:` of each line; value = the rest (string). A line
29
+ # without `:` is ignored. Preserves `: ` internal to the value (the case that breaks YAML).
30
+ def lenient(text)
31
+ text.to_s.each_line.each_with_object({}) do |line, acc|
32
+ next unless line.include?(":")
33
+
34
+ key, _, value = line.partition(":")
35
+ k = key.strip
36
+ acc[k] = value.strip unless k.empty?
37
+ end
38
+ end
39
+
40
+ def stringify(hash) = hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
41
+ end
42
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require "yaml"
6
+
7
+ module Insika
8
+ # AUTHORED eval cases (promoted to a store by).
9
+ #
10
+ # A golden case used to be a YAML file in `evals/golden/`, which means only someone
11
+ # with a checkout and a text editor could add one. The rubric is the part of an eval
12
+ # a domain owner can actually write ("consults the coupon by tool and does NOT invent
13
+ # a code") — so it has to be authorable where they already work, the Studio.
14
+ #
15
+ # One record per case in the ConfigStore (scope "goldens"):
16
+ # { "case" => { "id" =>, "agent" =>, "turns" => [...], "expect" => {...} },
17
+ # "updated_at" => iso8601 }
18
+ #
19
+ # The stored shape is the SAME mapping the YAML file holds, and every write is
20
+ # validated by `Evals::GoldenLoader.build` — the one validator, so a case authored in
21
+ # the Studio and a case read from disk cannot diverge. `evals/golden/**` stays the
22
+ # export format and the seed for a fresh deploy (`insika evals:import`), for the same
23
+ # reason the knowledge RFC keeps markdown as both: no converter to keep honest.
24
+ #
25
+ # No version history here, unlike the prompt/skill stores: the corpus on disk IS the
26
+ # backup, and re-importing restores it.
27
+ class GoldenStore
28
+ SCOPE = "goldens"
29
+
30
+ def initialize(config_store:)
31
+ @cs = config_store
32
+ end
33
+
34
+ # Upsert. `raw` is the case mapping (string or symbol keys), validated before it
35
+ # lands. -> Evals::Golden. InvalidGolden if malformed — a silently dropped case is
36
+ # a hole in the safety net, which is why the loader raises instead of skipping.
37
+ def write(raw, path: nil)
38
+ golden = Evals::GoldenLoader.build(Coercion.deep_stringify(raw), source: "(store)")
39
+ record = { "case" => case_hash(golden), "updated_at" => timestamp }
40
+ # Remember where it came from, so an export reproduces the corpus LAYOUT instead
41
+ # of renaming every file. The curated corpus groups the safety suite by PURPOSE
42
+ # (`safety/`) while its cases belong to an example agent — deriving the path from
43
+ # the agent would scatter them. An edit keeps whatever path it already had.
44
+ record["path"] = Coercion.presence(path) || @cs.get(SCOPE, golden.id)&.fetch("path", nil)
45
+ @cs.put(SCOPE, golden.id, record.compact)
46
+ golden
47
+ end
48
+
49
+ # -> Evals::Golden | nil. A stored case that no longer validates surfaces as nil
50
+ # rather than raising here; `insika doctor`-style sweeps are the place to shout.
51
+ def find(id)
52
+ record = @cs.get(SCOPE, id.to_s)
53
+ record && build(record)
54
+ end
55
+
56
+ # -> [String] case ids, lexicographic (a stable run order, like the file loader's
57
+ # sort by path).
58
+ def ids = @cs.keys(SCOPE)
59
+
60
+ # -> [Evals::Golden] every valid case, in id order.
61
+ def all = ids.filter_map { |id| find(id) }
62
+
63
+ # -> [Evals::Golden] one agent's cases.
64
+ def for_agent(agent_id) = all.select { |g| g.agent == agent_id.to_s }
65
+
66
+ # -> [String] ids whose stored mapping no longer validates (an edit that broke the
67
+ # shape). The Studio shows these; they never silently vanish from a run.
68
+ def invalid
69
+ ids.reject { |id| find(id) }
70
+ end
71
+
72
+ # -> bool (did it exist?)
73
+ def delete(id) = @cs.delete(SCOPE, id.to_s)
74
+
75
+ # Bulk import from the corpus on disk. Returns the ids written. `overwrite: false`
76
+ # keeps an already-authored case (the store wins over the seed, same rule the
77
+ # SkillCatalog overlay uses).
78
+ def import_dir(dir, overwrite: true)
79
+ root = File.expand_path(dir)
80
+ Evals::GoldenLoader.load_dir(dir).filter_map do |golden|
81
+ next if !overwrite && @cs.get(SCOPE, golden.id)
82
+
83
+ # Expand both sides: the loader's `source` is whatever shape `dir` was given in
84
+ # (a relative glob stays relative), so comparing raw strings would leave the
85
+ # corpus prefix inside the stored path.
86
+ relative = File.expand_path(golden.source.to_s).delete_prefix("#{root}/")
87
+ write(case_hash(golden), path: relative).id
88
+ end
89
+ end
90
+
91
+ # The export format IS the import format, written at the path the case came from
92
+ # (falling back to `<agent>/<id>.yml` for a case authored in the Studio). -> [paths]
93
+ #
94
+ # It is NOT a faithful copy of the curated corpus: `YAML.dump` drops the comments
95
+ # those files carry, and each one explains what its case is for. So exporting over
96
+ # an existing corpus demands `force` — losing that prose silently would be a bad
97
+ # trade for a convenience.
98
+ def export_dir(dir, force: false)
99
+ existing = Dir.glob(File.join(dir, "**", "*.{yml,yaml}"))
100
+ if existing.any? && !force
101
+ raise Insika::ValidationError,
102
+ "#{dir} already holds #{existing.size} case file(s); exporting rewrites them and " \
103
+ "DROPS their comments — pass --force to accept that, or export somewhere else"
104
+ end
105
+
106
+ all.map do |golden|
107
+ path = File.join(dir, path_of(golden))
108
+ FileUtils.mkdir_p(File.dirname(path))
109
+ File.write(path, YAML.dump(case_hash(golden)))
110
+ path
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def path_of(golden)
117
+ Coercion.presence(@cs.get(SCOPE, golden.id)&.fetch("path", nil)) ||
118
+ File.join(golden.agent, "#{golden.id}.yml")
119
+ end
120
+
121
+ def build(record)
122
+ Evals::GoldenLoader.build(record["case"] || {}, source: "(store)")
123
+ rescue Evals::GoldenLoader::InvalidGolden
124
+ nil
125
+ end
126
+
127
+ # Golden -> the plain mapping (what YAML holds and what the store persists).
128
+ # `source` is a load-time detail, never part of the case.
129
+ # `requires` is omitted when empty so a case that runs everywhere stays as short
130
+ # in the store as it is on disk — but it is NEVER dropped when present: a case
131
+ # that silently lost its requirements would come back as a failure on every
132
+ # deployment that lacks the tool, which is the exact lie `requires` exists to end.
133
+ def case_hash(golden)
134
+ h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
135
+ h["requires"] = golden.requires unless golden.requires.empty?
136
+ # Same rule for `reference`: omitted when absent, never dropped
137
+ # when present. A case that lost its reference in a round-trip would stop being
138
+ # compared against the incumbent and the report would look identical.
139
+ h["reference"] = golden.reference unless golden.reference.empty?
140
+ h.merge("expect" => golden.expect)
141
+ end
142
+
143
+ def timestamp = Time.now.utc.iso8601
144
+ end
145
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Hooks ALTER the input/output of the ONE stage they wrap: Middleware
5
+ # modifies, Hooks alter, Events observe. They don't create their own flow nor skip
6
+ # stages. Synchronous and without rescue — the error->state mapping belongs to the Executor.
7
+ class Hooks
8
+ PAIRS = %i[task prompt agent tool].freeze
9
+
10
+ def initialize
11
+ @before = Hash.new { |h, k| h[k] = [] }
12
+ @after = Hash.new { |h, k| h[k] = [] }
13
+ end
14
+
15
+ # callables; multiple per pair. Registration order is significant.
16
+ def register(pair, before: nil, after: nil)
17
+ raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
18
+
19
+ @before[pair] << before if before
20
+ @after[pair] << after if after
21
+ nil
22
+ end
23
+
24
+ # befores in registration order (may ALTER the subject by returning the new one),
25
+ # yield(subject), afters in REVERSE order (may alter the result). With no
26
+ # registrations -> degenerates into yield(subject) (no-op). A hook that doesn't alter
27
+ # returns what it received; returning nil IS altering to nil (no special case).
28
+ def around(pair, subject)
29
+ run_after(pair, yield(run_before(pair, subject)))
30
+ end
31
+
32
+ # Public halves of around. Needed for the :tool pair, whose
33
+ # stage "body" is RubyLLM's inner loop — there is no block
34
+ # to wrap; the halves are called from the before_tool_call/
35
+ # after_tool_result callbacks separately.
36
+ def run_before(pair, subject)
37
+ raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
38
+
39
+ @before[pair].reduce(subject) { |subj, hook| hook.call(subj) }
40
+ end
41
+
42
+ def run_after(pair, result)
43
+ raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
44
+
45
+ @after[pair].reverse.reduce(result) { |res, hook| hook.call(res) }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "coercion"
6
+
7
+ module Insika
8
+ # Default HTTP client for data-tools. Net::HTTP (stdlib, zero-dep — spec)
9
+ # with its own socket timeouts (mitigates reactor blocking even if the
10
+ # envelope timer doesn't fire) and a response-size CAP via streaming (
11
+ # avoids OOM). It is INJECTABLE: tests pass a double (none hit the network);
12
+ # can swap in async-http without touching DataDefinedTool.
13
+ #
14
+ # Contract: request(method:, url:, headers:, body:, timeout:) -> { status:, body: }
15
+ # (+ `location:` on a 3xx). It does NOT follow redirects: the destination is
16
+ # cleared by the EgressGuard in the CALLER (DataDefinedTool), before the call —
17
+ # a hop taken in here would carry the model's tool call to a host nobody
18
+ # allowed (an SSRF around the guard). A data-tool whose API moved gets a
19
+ # 3xx surfaced as an error naming the new URL, and the fix is to author the
20
+ # final URL in the definition.
21
+ class HttpClient
22
+ DEFAULT_TIMEOUT = 30
23
+ MAX_BYTES = 1_000_000 # 1 MB
24
+
25
+ class ResponseTooLarge < Insika::Error; end
26
+
27
+ def initialize(max_bytes: MAX_BYTES)
28
+ @max_bytes = max_bytes
29
+ end
30
+
31
+ def request(method:, url:, headers: {}, body: nil, timeout: nil)
32
+ uri = URI.parse(url)
33
+ req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
34
+ headers.each { |k, v| req[k] = v }
35
+ req.body = body if body && !body.to_s.empty?
36
+
37
+ t = timeout || DEFAULT_TIMEOUT
38
+ opts = { use_ssl: uri.scheme == "https", open_timeout: t, read_timeout: t }
39
+ Net::HTTP.start(uri.host, uri.port, opts) do |http|
40
+ result = nil
41
+ http.request(req) do |resp|
42
+ # Accumulate in BINARY: Net::HTTP yields ASCII-8BIT chunks and a
43
+ # multi-byte character can straddle two of them, so only byte
44
+ # concatenation is safe here. (A `+""` buffer would also SILENTLY turn
45
+ # BINARY the first time a chunk carried a non-ASCII byte.)
46
+ collected = +"".b
47
+ resp.read_body do |chunk|
48
+ collected << chunk
49
+ raise ResponseTooLarge, "response exceeds #{@max_bytes} bytes" if collected.bytesize > @max_bytes
50
+ end
51
+ # One tag at the end, over whole bytes: the body leaves here as valid
52
+ # UTF-8 (see Coercion.utf8) because it goes on to be a tool result —
53
+ # transcript, event, SSE frame — and JSON.generate rejects anything else.
54
+ result = { status: resp.code.to_i, body: Coercion.utf8(collected) }
55
+ # The redirect TARGET, so a moved API is reported as "moved to <url>"
56
+ # instead of a bare 3xx with the empty body servers send with it.
57
+ result[:location] = resp["location"].to_s if resp["location"]
58
+ end
59
+ result
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Short-lived memory of inbound event ids, so a platform's retry does not become
7
+ # a second LLM turn and a second reply.
8
+ #
9
+ # Every messaging platform retries a webhook it did not see acked in time, and a
10
+ # relay consumer that hands us its own queue does the same. Without this, the
11
+ # cost of one flaky ack is a duplicated turn (paid for) and a duplicated answer
12
+ # (visible to the customer). With it, the retry finds the id and gets the SAME
13
+ # task back — the caller learns it already sent this, which is a different fact
14
+ # from "your message was merged into someone else's turn".
15
+ #
16
+ # The id is the CALLER's (`wamid.…`, a Slack event id, whatever the consumer's
17
+ # queue uses). A caller that cannot supply a stable one gets at-least-once turns
18
+ # and is told so in the docs — the engine does not hash the content and call
19
+ # that dedup, because two customers legitimately typing "oi" one second apart is
20
+ # not a duplicate.
21
+ #
22
+ # TTL, not forever: this is a retry window, not an audit log. An expired entry is
23
+ # deleted lazily on read; `sweep` (boot) clears whatever nobody read back.
24
+ class InboundLog
25
+ SCOPE = "inbound"
26
+ KEY_PREFIX = "inbound:"
27
+
28
+ DEFAULT_TTL = 86_400 # 24h — longer than any platform's retry schedule
29
+
30
+ def initialize(store:, ttl: DEFAULT_TTL, clock: -> { Time.now.utc })
31
+ @store = store
32
+ @ttl = ttl.to_i
33
+ @clock = clock
34
+ end
35
+
36
+ # -> the task id this event already produced, or nil (never seen, or the
37
+ # window closed). An expired entry is removed as it is read: the next
38
+ # identical id is honestly a new message by then.
39
+ def find(key)
40
+ record = @store.get(SCOPE, key_for(key))
41
+ return nil if record.nil?
42
+
43
+ if expired?(record)
44
+ @store.delete(SCOPE, key_for(key))
45
+ return nil
46
+ end
47
+ record["task_id"]
48
+ end
49
+
50
+ # Remembers that `key` produced `task_id`. Last write wins, like every other
51
+ # store — a re-record inside the window just refreshes the expiry.
52
+ def record(key, task_id)
53
+ @store.set(SCOPE, key_for(key), {
54
+ "key" => key.to_s,
55
+ "task_id" => task_id&.to_s,
56
+ "expires_at" => (@clock.call + @ttl).iso8601
57
+ })
58
+ task_id
59
+ end
60
+
61
+ # Boot housekeeping: drops the entries nobody came back for. -> count removed.
62
+ def sweep
63
+ @store.list(SCOPE, KEY_PREFIX).count do |key|
64
+ record = @store.get(SCOPE, key)
65
+ next false unless record && expired?(record)
66
+
67
+ @store.delete(SCOPE, key)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def expired?(record)
74
+ at = record["expires_at"]
75
+ return false if at.nil? # no expiry recorded -> keep (fail-safe, not fail-open)
76
+
77
+ Time.parse(at.to_s) <= @clock.call
78
+ rescue ArgumentError
79
+ false
80
+ end
81
+
82
+ def key_for(key) = "#{KEY_PREFIX}#{key}"
83
+ end
84
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Applies the authored LLM providers (LLMProviderStore) to RubyLLM at RUNTIME.
5
+ # Goal: swap a provider's key/base WITHOUT a restart —
6
+ # RubyLLM exposes `config.<api>_api_key=` / `config.<api>_api_base=`, so
7
+ # reconfiguring is a matter of setting those accessors per provider.
8
+ #
9
+ # Core constraint: this file does NOT require ruby_llm at load-time — the
10
+ # `require` is lazy, inside `apply` (and injectable via `configure:` for tests,
11
+ # which run without the gem/key). A provider that RubyLLM doesn't recognize (no
12
+ # matching accessor) does NOT blow up: it goes into `skipped` (degrades to "restart
13
+ # recommended", like OpenClaw), the rest applies.
14
+ #
15
+ # ⚠️ GOTCHA — the DEFAULT target is a global singleton (RubyLLM research).
16
+ # With no `configure:`, `apply`/`unapply` mutate the PROCESS-WIDE config
17
+ # (`RubyLLM.config`). They are therefore admin operations (rare, operator-driven:
18
+ # a provider key/base edit in the Studio), NOT a per-request/per-turn path — a
19
+ # concurrent turn reading the config mid-mutation would observe a torn key/base.
20
+ # This is tolerable ONLY because reconfiguration is infrequent and single-writer
21
+ # (one reactor). Do NOT call this per turn, and do NOT reach for it to vary the
22
+ # MODEL per turn — the model is chosen at chat build time (ModelResolver ->
23
+ # chat(model:)), never by mutating the global.
24
+ #
25
+ # PER-GRAPH credentials are no longer hypothetical: the DSL runtime
26
+ # passes `configure:` targeting its own `RubyLLM.context` — an isolated config dup
27
+ # — so an operator's key edit in an EMBEDDED graph applies to that graph and stops
28
+ # there. See `DSL::Runtime#llm_configure` and docs/EMBEDDING.md.
29
+ class LLMConfigurator
30
+ def initialize(provider_store:, configure: nil)
31
+ @provider_store = provider_store
32
+ @configure = configure # ->(&blk){ blk.call(config_target) }; default = RubyLLM
33
+ end
34
+
35
+ # Reconfigures RubyLLM with `providers` (raw records, with the real api_key) or,
36
+ # if nil, with ALL from the store. -> { applied: [api], skipped: [{api:, reason:}] }.
37
+ def apply(providers = nil)
38
+ records = providers || @provider_store.all_raw
39
+ applied = []
40
+ skipped = []
41
+
42
+ with_config do |config|
43
+ records.each do |rec|
44
+ api = fetch(rec, :api)
45
+ key = fetch(rec, :api_key)
46
+ if key.nil? || key.to_s.empty?
47
+ skipped << { api: api, reason: "sem api_key" }
48
+ next
49
+ end
50
+
51
+ if set_accessor(config, "#{api}_api_key", key)
52
+ base = fetch(rec, :base_url)
53
+ set_accessor(config, "#{api}_api_base", base) if base && !base.to_s.empty?
54
+ applied << api
55
+ else
56
+ skipped << { api: api, reason: "provider '#{api}' not recognized by RubyLLM" }
57
+ end
58
+ end
59
+ end
60
+
61
+ { applied: applied, skipped: skipped }
62
+ end
63
+
64
+ # UNDOES a provider's config in RubyLLM at runtime (delete without a restart,
65
+ # clears `<api>_api_key`/`<api>_api_base`. A provider that RubyLLM
66
+ # doesn't recognize (no accessor) -> unapplied: false (nothing applied, nothing to undo).
67
+ # -> { unapplied: bool }.
68
+ def unapply(api)
69
+ api = api.to_s
70
+ unapplied = false
71
+ with_config do |config|
72
+ unapplied = set_accessor(config, "#{api}_api_key", nil)
73
+ set_accessor(config, "#{api}_api_base", nil)
74
+ end
75
+ { unapplied: unapplied }
76
+ end
77
+
78
+ private
79
+
80
+ def with_config(&blk)
81
+ if @configure
82
+ @configure.call(&blk)
83
+ else
84
+ require "ruby_llm"
85
+ RubyLLM.configure(&blk)
86
+ end
87
+ end
88
+
89
+ def set_accessor(config, name, value)
90
+ setter = "#{name}="
91
+ return false unless config.respond_to?(setter)
92
+
93
+ config.public_send(setter, value)
94
+ true
95
+ end
96
+
97
+ def fetch(rec, key) = rec[key.to_s] || rec[key.to_sym]
98
+ end
99
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LLM providers authored at runtime. One record per
5
+ # provider in the ConfigStore (scope "llm_providers"), keyed by the API slug
6
+ # (`deepseek`, `openai`, ...). Holds base_url/auth_header/models and the `api_key`.
7
+ #
8
+ # The `api_key` NEVER leaves here in plaintext to the UI: the display reads
9
+ # (`get`/`all`) mask it with the `__OCULTO__` sentinel. Only `all_raw`/`get_raw`
10
+ # (consumed by the LLMConfigurator, never by the screen) return the real key.
11
+ # On write, the sentinel coming back preserves the key; a new string replaces it; ""
12
+ # clears it (see Insika::SecretMasking).
13
+ class LLMProviderStore
14
+ include Coercion
15
+
16
+ SCOPE = "llm_providers"
17
+
18
+ def initialize(config_store:)
19
+ @cs = config_store
20
+ end
21
+
22
+ # -> MASKED Hash | nil.
23
+ def get(api)
24
+ mask(raw(api))
25
+ end
26
+
27
+ # -> Hash with REAL api_key | nil. Internal use (LLMConfigurator).
28
+ def get_raw(api)
29
+ raw(api)
30
+ end
31
+
32
+ # -> [String] slugs, lexicographic order.
33
+ def apis = @cs.keys(SCOPE)
34
+
35
+ # -> [Hash] all MASKED (for the UI).
36
+ def all
37
+ apis.filter_map { |a| get(a) }
38
+ end
39
+
40
+ # -> [Hash] all with REAL api_key (for the configurator). Never goes to the screen.
41
+ def all_raw
42
+ apis.filter_map { |a| raw(a) }
43
+ end
44
+
45
+ # Upsert with secret reconciliation. `attrs` (string|symbol keys):
46
+ # api (required), base_url, auth_header, api_key (sentinel-aware), models[]
47
+ # -> MASKED Hash (the stored record).
48
+ def upsert(attrs)
49
+ h = symbolize(attrs)
50
+ api = presence(h[:api])
51
+ raise Insika::ValidationError, "api is required" if api.nil?
52
+
53
+ existing = raw(api)
54
+ record = {
55
+ "api" => api,
56
+ "base_url" => presence(h[:base_url]),
57
+ "auth_header" => presence(h[:auth_header]),
58
+ "api_key" => SecretMasking.reconcile(h[:api_key], existing&.fetch("api_key", nil)),
59
+ "models" => Array(h[:models]).map(&:to_s)
60
+ }
61
+ @cs.put(SCOPE, api, record)
62
+ mask(record)
63
+ end
64
+
65
+ # -> bool (did it exist?).
66
+ def delete(api) = @cs.delete(SCOPE, api.to_s)
67
+
68
+ private
69
+
70
+ def raw(api) = @cs.get(SCOPE, api.to_s)
71
+
72
+ # Swaps the real api_key for the sentinel (or nil if absent) — never leaks plaintext.
73
+ def mask(record)
74
+ return nil if record.nil?
75
+
76
+ record.merge("api_key" => SecretMasking.mask(record["api_key"]))
77
+ end
78
+
79
+ def symbolize(attrs)
80
+ (attrs || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
81
+ end
82
+ end
83
+ end