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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/semaphore"
5
+
6
+ module Insika
7
+ # Turn-scoped assembly of the agent's tool instances (pipeline stage 3 tail):
8
+ # capability resolution, instantiation (Entry#factory | ready instance),
9
+ # turn-context injection, the capability<->direct dedup join, and the
10
+ # ToolEnvelope wrap (stage 7 seam: per-call timeout + side-effect recording).
11
+ #
12
+ # Extracted from the Executor to keep the hot-path file smaller. It is
13
+ # a pure collaborator — it holds only the injected registries/stores and no
14
+ # per-turn state; everything turn-specific arrives via `state`/`turn_context`.
15
+ # The Executor keeps thin delegators (resolve_capabilities/assemble_tool_instances/
16
+ # wrap_tools) so the existing private-method contract stays intact.
17
+ class ToolAssembly
18
+ def initialize(tool_registry:, capability_registry:, event_stream:,
19
+ checkpoint_store:, tool_trace_store:)
20
+ @tool_registry = tool_registry
21
+ @capability_registry = capability_registry
22
+ @event_stream = event_stream
23
+ @checkpoint_store = checkpoint_store
24
+ @tool_trace_store = tool_trace_store
25
+ end
26
+
27
+ # Resolution sub-step BETWEEN Context and Policy —
28
+ # it does NOT feed candidate_tools (those stay ONLY tool_registry.entries,
29
+ # a capability does not go through the ToolAllowlist). Resolves each
30
+ # capability of the profile to the concrete Entry already registered in the
31
+ # tool_registry and keeps the impl_name -> capability_name mapping for the
32
+ # post-Policy join. Errors
33
+ # (Unavailable/Ambiguous, or an unregistered impl) propagate as a
34
+ # CapabilityError -> single capture in `execute` (stage :capability). Without
35
+ # @capability_registry OR without profile.capabilities: {} (parity).
36
+ def resolve_capabilities(profile, context)
37
+ return {} if @capability_registry.nil?
38
+
39
+ Array(profile.capabilities).each_with_object({}) do |cap_name, names|
40
+ provider = @capability_registry.resolve(cap_name, profile: profile, context: context,
41
+ event_stream: @event_stream)
42
+ next if provider.kind == :workflow # exposure to the agent loop is a follow-up
43
+
44
+ entry = @tool_registry.entries.find { |e| e.name == provider.impl_name.to_s }
45
+ if entry.nil?
46
+ raise CapabilityError, "capability '#{cap_name}' resolveu para impl " \
47
+ "'#{provider.impl_name}', not registered in tool_registry"
48
+ end
49
+
50
+ names[entry.name] ||= cap_name.to_s # the 1st capability to claim an impl wins
51
+ end
52
+ end
53
+
54
+ # Joins the direct instances (Policy/ToolAllowlist) with the
55
+ # capability-sourced ones (grant = profile.capabilities — they never went
56
+ # through Policy). Avoids double-exposure: if the SAME impl_name was also
57
+ # allowed directly, the DIRECT instance is discarded — the model sees only the
58
+ # capability alias.
59
+ def assemble_tool_instances(allowed, state)
60
+ names = state.respond_to?(:capability_names) ? (state.capability_names || {}) : {}
61
+ ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
62
+ return instantiate_tools(allowed, ctx) if names.empty?
63
+
64
+ # Dedup by the ENTRY NAME (registry key = impl_name) BEFORE
65
+ # instantiating — the INSTANCE's `.name` (RubyLLM) is not the registration
66
+ # name.
67
+ direct = Array(allowed).reject { |e| e.respond_to?(:name) && names.key?(e.name.to_s) }
68
+ instantiate_tools(direct, ctx) + capability_tool_instances(names, ctx)
69
+ end
70
+
71
+ # Envelopes each allowed tool (per-call timeout + side-effect recording).
72
+ # The system LoadSkill (configure_chat) is NOT enveloped — it is a system
73
+ # tool with no side-effect and of trivial latency.
74
+ def wrap_tools(tools, state, skip_side_effects = [])
75
+ timeout = state.profile.limits[:tool_timeout] || 60
76
+ install_tool_gate(state)
77
+ tools.map do |tool|
78
+ ToolEnvelope.new(tool, state: state, checkpoint_store: @checkpoint_store,
79
+ tool_registry: @tool_registry, timeout: timeout,
80
+ skip_side_effects: skip_side_effects,
81
+ trace_recorder: @tool_trace_store)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # the model decides the fan-out, so without a cap a batch of 15
88
+ # data-tools is 15 simultaneous requests to the same upstream — which is how
89
+ # one turn earns a 429 for every other turn in the process. Note the contrast
90
+ # with the primitives that already shipped: `spawn_subagents` caps at 8
91
+ # (SubagentGraph.fan_out_cap) and `Tools::Concurrency.gather` takes `max:`.
92
+ #
93
+ # The cap is enforced in OUR decorator, not in the gem (which has none): ONE
94
+ # semaphore per turn, installed here — once, in the turn's own fiber, before
95
+ # any tool can run — and acquired by every ToolEnvelope. Off (nil) unless the
96
+ # turn actually got concurrency, so the serial path allocates nothing.
97
+ # A state that predates these fields (a unit stub) is left alone.
98
+ def install_tool_gate(state)
99
+ return unless state.respond_to?(:tool_gate) && state.respond_to?(:tool_concurrency)
100
+
101
+ cap = state.tool_concurrency
102
+ state.tool_gate = cap ? Async::Semaphore.new(cap) : nil
103
+ end
104
+
105
+ # Real Engine -> Entries (respond to factory); fakes -> ready instances.
106
+ # `turn_context` is deposited into the instances that expose it
107
+ # (data-tools); the rest ignore it (parity).
108
+ def instantiate_tools(allowed, turn_context = nil)
109
+ Array(allowed).map do |t|
110
+ tool = t.respond_to?(:factory) ? t.factory.call : t
111
+ inject_turn_context(tool, turn_context)
112
+ tool
113
+ end
114
+ end
115
+
116
+ # seam: deposits the turn context into the freshly created instance
117
+ # (same idea as `remember`, which receives tenant/state) BEFORE the
118
+ # ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
119
+ # receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
120
+ def inject_turn_context(tool, turn_context)
121
+ return if turn_context.nil?
122
+
123
+ tool.turn_context = turn_context if tool.respond_to?(:turn_context=)
124
+ end
125
+
126
+ # impl_name -> Capability::ResolvedTool(capability_name:), STILL without
127
+ # ToolEnvelope (the call site's wrap_tools wraps the whole set — same
128
+ # order impl -> ResolvedTool -> ToolEnvelope). entry already validated in
129
+ # resolve_capabilities.
130
+ def capability_tool_instances(names, turn_context = nil)
131
+ names.map do |impl_name, capability_name|
132
+ entry = @tool_registry.entries.find { |e| e.name == impl_name }
133
+ tool = entry.factory.call
134
+ inject_turn_context(tool, turn_context)
135
+ Capability::ResolvedTool.new(tool, capability_name: capability_name,
136
+ impl_name: impl_name)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # "Level 1" (progressive disclosure) view of the tools: just
5
+ # name+description, the analog of SkillCatalog for tools. It does NOT read
6
+ # disk (the tools are already in the ToolRegistry, registered at boot) and does NOT
7
+ # inherit from RubyLLM::Tool — duck typing over `.description` (pure, testable without
8
+ # the gem).
9
+ #
10
+ # The canonical `description` does not live in the Entry (Registry::Entry has no such
11
+ # field): it comes from the tool INSTANCE (`factory.call.description`). That is why the
12
+ # catalog is LAZY: it only instantiates the tools on the first query (`all`), not at
13
+ # boot. A deployment without `tools_deferred` never touches the catalog and pays for
14
+ # no instantiation at all; a broken factory surfaces on first use (where the
15
+ # Executor would also catch it at stage 3), not at construction.
16
+ class ToolCatalog
17
+ Entry = Data.define(:name, :description)
18
+
19
+ def initialize(tool_registry:)
20
+ @tool_registry = tool_registry
21
+ end
22
+
23
+ def all
24
+ @entries ||= build_entries
25
+ end
26
+
27
+ # Reloads the index (after authoring a data-tool in the overlay). Mirrors
28
+ # SkillCatalog#reload — level-1/tool_search starts seeing the new tool without a
29
+ # restart. An in-flight turn has already captured `all`.
30
+ def reload
31
+ @entries = build_entries
32
+ self
33
+ end
34
+
35
+ # Allowed deferred slice (typically allowed_tools ∩ tools_deferred).
36
+ # Names outside the catalog are silently ignored (safe failure: less
37
+ # exposure, never more).
38
+ def subset(names)
39
+ wanted = Array(names).map(&:to_s)
40
+ all.select { |e| wanted.include?(e.name) }
41
+ end
42
+
43
+ # PURE matcher: case-insensitive, substring/keyword, NO
44
+ # embeddings. name weighs 2, description weighs 1; ties broken by original index
45
+ # (Ruby's sort_by is not stable). `within:` restricts the universe via subset.
46
+ def search(query, within: nil)
47
+ terms = query.to_s.downcase.split(/\s+/).reject(&:empty?)
48
+ return [] if terms.empty?
49
+
50
+ universe = within ? subset(within) : all
51
+ scored = universe.each_with_index.filter_map do |entry, idx|
52
+ score = score_entry(entry, terms)
53
+ [entry, score, idx] if score.positive?
54
+ end
55
+ scored.sort_by { |_entry, score, idx| [-score, idx] }.map(&:first)
56
+ end
57
+
58
+ # Level 1 injected into the prompt — mirror of SkillCatalog#format_for_prompt,
59
+ # swapping the tag and the final instruction (load_skill -> tool_search).
60
+ def format_for_prompt(entries = all)
61
+ return "" if entries.empty?
62
+
63
+ lines = entries.map { |e| %( <tool name="#{e.name}">#{e.description}</tool>) }.join("\n")
64
+
65
+ <<~PROMPT.strip
66
+ <available_tools>
67
+ #{lines}
68
+ </available_tools>
69
+
70
+ Before using a tool above, call `tool_search` with what you need
71
+ to do to enable it in this conversation.
72
+ PROMPT
73
+ end
74
+
75
+ private
76
+
77
+ def build_entries
78
+ @tool_registry.entries.map do |entry|
79
+ Entry.new(name: entry.name, description: entry.factory.call.description.to_s)
80
+ end
81
+ end
82
+
83
+ def score_entry(entry, terms)
84
+ name = entry.name.downcase
85
+ desc = entry.description.downcase
86
+ terms.sum { |term| (name.include?(term) ? 2 : 0) + (desc.include?(term) ? 1 : 0) }
87
+ end
88
+ end
89
+ end