insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Hands freed memory back to the OS once a worker goes quiet. After a burst, glibc
5
+ # keeps what the turns freed inside its malloc arenas and the process stays at its
6
+ # peak until something calls malloc_trim — a full GC alone returns nothing. On a
7
+ # quiet pass (no turn in flight, enough allocated since the last trim) this runs a
8
+ # full GC and then malloc_trim(0).
9
+ #
10
+ # A no-op where malloc_trim does not exist (macOS, musl, a jemalloc LD_PRELOAD
11
+ # returns 0) or where `fiddle` is not in the bundle: it is not a gem dependency,
12
+ # the reference deployment adds it to its Gemfile.
13
+ class MemoryTrim
14
+ INTERVAL = 60 # seconds between checks
15
+ MIN_ALLOCATED = 1_000_000 # objects allocated since the last trim; less is not worth a full GC
16
+
17
+ def self.malloc_trim
18
+ require "fiddle"
19
+ fn = Fiddle::Function.new(Fiddle::Handle::DEFAULT["malloc_trim"], [Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
20
+ -> { fn.call(0) }
21
+ rescue LoadError, StandardError # no fiddle, or no malloc_trim symbol (Fiddle::DLError)
22
+ nil
23
+ end
24
+
25
+ def initialize(executor:, trim: self.class.malloc_trim, allocated: -> { GC.stat(:total_allocated_objects) })
26
+ @executor = executor
27
+ @trim = trim
28
+ @allocated = allocated
29
+ @last = @allocated.call
30
+ end
31
+
32
+ def enabled? = !@trim.nil?
33
+
34
+ # One check. -> true when it trimmed.
35
+ def run_once
36
+ return false unless @executor.in_flight.empty?
37
+ return false if @allocated.call - @last < MIN_ALLOCATED
38
+
39
+ GC.start
40
+ @trim.call
41
+ @last = @allocated.call
42
+ true
43
+ end
44
+
45
+ def start(parent:, interval: INTERVAL)
46
+ return false unless enabled?
47
+
48
+ parent.async do |t|
49
+ t.annotate("insika-memory-trim")
50
+ loop do
51
+ sleep(interval)
52
+ run_once
53
+ rescue StandardError => e
54
+ warn "[memory_trim] #{e.class}: #{e.message}"
55
+ end
56
+ end
57
+ true
58
+ end
59
+ end
60
+ end
@@ -35,11 +35,20 @@ module Insika
35
35
  self
36
36
  end
37
37
 
38
- def call(state, &terminal)
39
- chain = @middlewares.reverse.reduce(terminal) do |nxt, mw|
38
+ # Paid retrieval prepares context inside the leading edge limiter. Other
39
+ # middleware still receives the completed context and policy resolution.
40
+ def call(state, prepare: nil, &terminal)
41
+ edge = @middlewares.first if prepare && @middlewares.first.is_a?(EdgeLimiter)
42
+ links = edge ? @middlewares.drop(1) : @middlewares
43
+ chain = links.reverse.reduce(terminal) do |nxt, mw|
40
44
  proc { |s| mw.call(s, &nxt) }
41
45
  end
42
- chain.call(state)
46
+ if edge
47
+ edge.call(state, prepare: prepare, &chain)
48
+ else
49
+ prepare&.call(state)
50
+ chain.call(state)
51
+ end
43
52
  end
44
53
  end
45
54
  end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Content-free request summaries; independent of the bounded diagnostic trace.
7
+ class ModelMetricsStore
8
+ SCOPE = "model_metrics"
9
+ # One row per completed turn (TurnTiming#metrics + agent/model/provider),
10
+ # keyed by task id; TaskStore#delete drops it with the task.
11
+ TURN_SCOPE = "turn_metrics"
12
+ # One row per closed UTC day the home charts: cost and requests, keyed "agent|YYYY-MM-DD"
13
+ # ("*" = every agent). See #home.
14
+ DAY_SCOPE = "model_metrics_day"
15
+ TURN_WINDOWS = %w[ttft_ms total_ms first_balloon_ms queue_ms prep_ms tools_ms].freeze
16
+ PERIODS = { "24h" => 86_400, "7d" => 7 * 86_400, "30d" => 30 * 86_400 }.freeze
17
+ VALUES = %w[cost input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens].freeze
18
+ FIELDS = %w[type request_id at operation agent provider model status duration_ms turn].freeze + VALUES
19
+
20
+ def initialize(store:)
21
+ @store = store
22
+ end
23
+
24
+ def self.task_prefix(task_id) = "#{task_id.to_s.bytesize}:#{task_id}:"
25
+
26
+ def record(task_id:, entry:) = record_many(task_id: task_id, entries: [entry])
27
+
28
+ # A turn's events in one transaction: each request's row is read and written
29
+ # once, however many events (request, usage, retries) it got.
30
+ def record_many(task_id:, entries:)
31
+ rows = entries.filter_map do |entry|
32
+ data = LLMTraceStore.sanitize(entry.slice(*FIELDS))
33
+ next unless %w[llm_request llm_usage].include?(data["type"])
34
+ next if data["request_id"].nil? || data["request_id"].empty?
35
+
36
+ data
37
+ end
38
+ return if rows.empty?
39
+
40
+ @store.transaction do
41
+ next unless @store.get(TaskStore::SCOPE, "#{TaskStore::KEY_PREFIX}#{task_id}")
42
+
43
+ rows.group_by { |data| data["request_id"] }.each do |request_id, events|
44
+ key = self.class.task_prefix(task_id) + request_id
45
+ row = @store.get(SCOPE, key) || {
46
+ "task_id" => task_id.to_s, "request_id" => request_id,
47
+ "attempts" => 0, "failed_attempts" => 0, "reported" => {}
48
+ }
49
+ events.each { |data| fold(row, data) }
50
+ @store.set(SCOPE, key, row)
51
+ end
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ def record_turn(task_id:, row:)
58
+ @store.set(TURN_SCOPE, task_id.to_s, row)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ # One agent's (or everyone's) totals and turn summary for each fixed period,
64
+ # from a single read of the 30-day rows. -> { "24h" => { "totals", "turns" }, ... }
65
+ def windows(agent: nil, now: Time.now.utc)
66
+ from = now - PERIODS.values.max
67
+ rows = request_rows(from, now).select { |row| agent.nil? || row["agent"] == agent }
68
+ turns = turn_rows(from, now, agent: agent, provider: nil, model: nil)
69
+ PERIODS.to_h do |period, seconds|
70
+ cut = now - seconds
71
+ [period, { "totals" => summarize(rows.select { |row| Time.iso8601(row["completed_at"]) >= cut }),
72
+ "turns" => turn_summary(turns.select { |row| Time.iso8601(row["at"]) >= cut }) }]
73
+ end
74
+ end
75
+
76
+ # Everything the Studio home shows: the last 24h (totals + turns), the cost
77
+ # of the 24h before, and one bucket per UTC day (oldest first, today last).
78
+ # Rows are read for the last 48h only. A day that ended before that never
79
+ # changes again, so its cost and request count are kept in DAY_SCOPE the first
80
+ # time a home needs it: 14 days of rows, scattered across a big file, cost
81
+ # seconds on a cold disk.
82
+ # ponytail: day rows are never pruned (one per day per agent); a task deleted
83
+ # later still counts in a kept day.
84
+ def home(agent: nil, now: Time.now.utc, days: 14)
85
+ today = Time.utc(now.year, now.month, now.day)
86
+ day_ago = now - 86_400
87
+ recent_from = day_ago - 86_400
88
+ day_starts = Array.new(days) { |i| today - (days - 1 - i) * 86_400 }
89
+ tag = "#{agent || '*'}|"
90
+ kept = @store.entries(DAY_SCOPE, tag).to_h { |key, value| [key.delete_prefix(tag), value] }
91
+ missing = day_starts.select { |day| day + 86_400 <= recent_from && !kept.key?(day.strftime("%F")) }
92
+ timed = request_rows([recent_from, *missing].min, now)
93
+ .select { |row| agent.nil? || row["agent"] == agent }
94
+ .map { |row| [row, Time.iso8601(row["completed_at"])] }
95
+ by_day = timed.group_by { |_, t| t.strftime("%F") }.transform_values { |pairs| pairs.map(&:first) }
96
+ day_total = ->(key) { summarize(by_day.fetch(key, [])).slice("cost", "requests") }
97
+ fresh = missing.to_h { |day| [day.strftime("%F"), day_total.(day.strftime("%F"))] }
98
+ keep_days(tag, fresh)
99
+ {
100
+ "totals" => summarize(timed.filter_map { |row, t| row if t >= day_ago }),
101
+ "turns" => turn_summary(turn_rows(day_ago, now, agent: agent, provider: nil, model: nil)),
102
+ "day_before_cost" => summarize(timed.filter_map { |row, t| row if t >= recent_from && t < day_ago })["cost"],
103
+ "daily" => day_starts.map do |day|
104
+ key = day.strftime("%F")
105
+ (kept[key] || fresh[key] || day_total.(key)).merge("at" => day.iso8601)
106
+ end
107
+ }
108
+ end
109
+
110
+ # `from`/`to` (Time) pick a custom range and win over `period`; the bucket size
111
+ # follows the span (see #bucket_count).
112
+ def report(period: "7d", agent: nil, provider: nil, model: nil, now: Time.now.utc, from: nil, to: nil)
113
+ if from && to
114
+ period, now = "custom", to
115
+ else
116
+ period = "7d" unless PERIODS.key?(period)
117
+ from = now - PERIODS.fetch(period)
118
+ end
119
+ rows = request_rows(from, now)
120
+ agents = rows.filter_map { |row| row["agent"] }.uniq.sort
121
+ providers = rows.filter_map { |row| row["provider"] }.uniq.sort
122
+ model_options = rows.filter_map { |row| row["model"] }.uniq.sort
123
+ rows.select! do |row|
124
+ (agent.nil? || row["agent"] == agent) &&
125
+ (provider.nil? || row["provider"] == provider) && (model.nil? || row["model"] == model)
126
+ end
127
+ {
128
+ "period" => period, "from" => from.utc.iso8601, "to" => now.utc.iso8601,
129
+ "step_seconds" => ((now - from) / bucket_count(period, now - from)).round,
130
+ "agents" => agents, "providers" => providers, "model_options" => model_options,
131
+ "totals" => summarize(rows),
132
+ "series" => time_series(rows, from: from, to: now, period: period),
133
+ "models" => rows.group_by { |row| [row["provider"], row["model"]] }
134
+ .sort_by { |key, _| key.map(&:to_s) }.map do |(name, model_name), group|
135
+ summarize(group).merge("provider" => name, "model" => model_name)
136
+ end,
137
+ # The same model serves the reply and the post-turn learning: this splits
138
+ # their cost ("chat" vs knowledge_*). Rows from before labeling read as "chat".
139
+ "operations" => rows.group_by { |row| row["operation"] || "chat" }.sort.map do |operation, group|
140
+ summarize(group).merge("operation" => operation)
141
+ end,
142
+ "turns" => turn_report(from, now, agent: agent, provider: provider, model: model),
143
+ "slowest" => rows.select { |row| row["duration_ms"] }.sort_by { |row| -row["duration_ms"] }
144
+ .first(20).map { |row| row.reject { |key, _| key == "reported" }.merge(summarize([row])) },
145
+ "history_note" => "History starts with deployment. Deleting a task removes its model history."
146
+ }
147
+ end
148
+
149
+ private
150
+
151
+ # a failed write only means the next home computes those days again
152
+ def keep_days(tag, days)
153
+ return if days.empty?
154
+
155
+ @store.transaction { days.each { |key, value| @store.set(DAY_SCOPE, "#{tag}#{key}", value) } }
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ def request_rows(from, to) = rows_between(SCOPE, "completed_at", from, to)
161
+
162
+ RECENT_PAGE = 500
163
+ # A row is written when its turn ends (or flushes before waiting), so its
164
+ # write can trail its own timestamp by up to a turn. Paging stops only past
165
+ # this margin, so a late write is never cut off.
166
+ LATE_WRITE = 6 * 3600
167
+
168
+ # The rows whose `field` falls in [from, to], read newest-written first and
169
+ # stopping at the first page entirely older than `from` (minus LATE_WRITE):
170
+ # a window costs its own rows, not the whole history. The stop test reads the
171
+ # page's oldest RECENT_PAGE rows, so a doubled page stops as early as a small one.
172
+ # ponytail: offset paging over write order; a time-keyed index if windows reach far back.
173
+ def rows_between(scope, field, from, to)
174
+ floor = from - LATE_WRITE
175
+ rows = []
176
+ offset = 0
177
+ limit = RECENT_PAGE
178
+ loop do
179
+ page = @store.recent(scope, nil, limit, offset)
180
+ times = page.map { |_key, row| row.is_a?(Hash) && row[field] ? Time.iso8601(row[field]) : nil }
181
+ page.each_with_index { |(_key, row), i| rows << row if times[i] && times[i] >= from && times[i] <= to }
182
+ break if page.size < limit || times.last(RECENT_PAGE).all? { |t| t && t < floor }
183
+
184
+ offset += limit
185
+ limit *= 2 # each call orders the whole scope: few big pages, not many small ones
186
+ end
187
+ rows
188
+ end
189
+
190
+ def turn_report(from, to, agent:, provider:, model:)
191
+ turn_summary(turn_rows(from, to, agent: agent, provider: provider, model: model))
192
+ end
193
+
194
+ def turn_rows(from, to, agent:, provider:, model:)
195
+ rows_between(TURN_SCOPE, "at", from, to).select do |row|
196
+ (agent.nil? || row["agent"] == agent) &&
197
+ (provider.nil? || row["provider"] == provider) && (model.nil? || row["model"] == model)
198
+ end
199
+ end
200
+
201
+ # Turn latency (each window's p50/p95) and per-tool health over turn rows.
202
+ def turn_summary(turns)
203
+ windows = TURN_WINDOWS.to_h do |window|
204
+ values = turns.filter_map { |row| row[window] }.sort
205
+ [window, { "measured" => values.size, "p50" => percentile(values, 50), "p95" => percentile(values, 95) }]
206
+ end
207
+ tools = turns.flat_map { |row| Array(row["tools"]) }.group_by(&:first).sort.map do |name, calls|
208
+ ms = calls.filter_map { |call| call[2] }.sort
209
+ { "tool" => name, "calls" => calls.size, "failures" => calls.count { |call| call[1] == false },
210
+ "p50_ms" => percentile(ms, 50), "p95_ms" => percentile(ms, 95) }
211
+ end
212
+ failed = turns.select { |row| row["status"] == "failed" }
213
+ { "count" => turns.size, "failures" => failed.size,
214
+ "failure_stages" => failed.group_by { |row| row["stage"] || "unknown" }.transform_values(&:size).sort.to_h,
215
+ "windows" => windows, "tools" => tools }
216
+ end
217
+
218
+ # Nearest rank on a sorted list; nil when empty.
219
+ def percentile(sorted, pct) = sorted.empty? ? nil : sorted[(sorted.size * pct / 100.0).ceil - 1]
220
+
221
+ def fold(row, data)
222
+ row.merge!(data.slice("operation", "agent", "provider", "model", "turn").compact)
223
+ if data["type"] == "llm_request"
224
+ row.merge!(data.slice("status", "duration_ms"))
225
+ row["completed_at"] = Time.iso8601(data.fetch("at")).utc.iso8601(6)
226
+ else
227
+ row["attempts"] += 1
228
+ row["failed_attempts"] += 1 if data["status"] == "failed"
229
+ VALUES.each do |field|
230
+ next if data[field].nil?
231
+
232
+ row[field] = row.fetch(field, 0) + data[field]
233
+ row["reported"][field] = row["reported"].fetch(field, 0) + 1
234
+ end
235
+ end
236
+ end
237
+
238
+ HOUR = 3600
239
+ MAX_BUCKETS = 92
240
+
241
+ # Fixed periods keep their buckets; a custom range goes hourly up to 2 days,
242
+ # 6-hourly up to 14, daily beyond (capped, so a year stays a readable chart).
243
+ def bucket_count(period, span)
244
+ fixed = { "24h" => 24, "7d" => 28, "30d" => 30 }[period]
245
+ return fixed if fixed
246
+
247
+ base = if span <= 48 * HOUR then HOUR elsif span <= 14 * 24 * HOUR then 6 * HOUR else 24 * HOUR end
248
+ (span / base).ceil.clamp(1, MAX_BUCKETS)
249
+ end
250
+
251
+ def time_series(rows, from:, to:, period:)
252
+ count = bucket_count(period, to - from)
253
+ step = (to - from) / count
254
+ buckets = Array.new(count) { [] }
255
+ rows.each do |row|
256
+ index = [((Time.iso8601(row["completed_at"]) - from) / step).floor, count - 1].min
257
+ buckets[index] << row
258
+ end
259
+ buckets.each_with_index.map do |group, index|
260
+ summarize(group).merge("at" => (from + index * step).utc.iso8601,
261
+ "until" => (from + (index + 1) * step).utc.iso8601)
262
+ end
263
+ end
264
+
265
+ def summarize(rows)
266
+ durations = rows.filter_map { |row| row["duration_ms"] }.sort
267
+ result = {
268
+ "requests" => rows.size, "attempts" => rows.sum { |row| row["attempts"] },
269
+ "failures" => rows.count { |row| row["status"] == "failed" },
270
+ "failed_attempts" => rows.sum { |row| row["failed_attempts"] },
271
+ "retries" => rows.sum { |row| [row["attempts"] - 1, 0].max },
272
+ "measured_requests" => durations.size
273
+ }
274
+ [50, 90, 95].each { |pct| result["p#{pct}_ms"] = percentile(durations, pct) }
275
+ VALUES.each do |field|
276
+ values = rows.filter_map { |row| row[field] }
277
+ result[field] = values.empty? ? nil : values.sum
278
+ result["unknown_#{field}_requests"] = rows.count do |row|
279
+ row["attempts"].zero? || row["reported"].fetch(field, 0) < row["attempts"]
280
+ end
281
+ end
282
+ result
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Insika
7
+ # RubyLLM's model catalog (ids, context windows, prices) kept in the shared
8
+ # store instead of the gem's bundled file, which ages with every deploy.
9
+ #
10
+ # Two roles:
11
+ # - RubyLLM's `model_registry_store` (#read / #write): the catalog lives in
12
+ # one place for every worker and survives restarts.
13
+ # - a Tick duty (#run): one worker per interval refreshes from the published
14
+ # catalog and the configured providers; every other worker reloads the
15
+ # stored catalog when it changes. A failed refresh keeps the catalog it had
16
+ # and is retried after RETRY_AFTER, never on every tick.
17
+ class ModelRegistry
18
+ SCOPE = "model_registry"
19
+ INTERVAL = 86_400
20
+ RETRY_AFTER = 3600
21
+
22
+ def initialize(store:, interval: INTERVAL, logger: nil,
23
+ refresher: -> { RubyLLM::Models.refresh(remote_only: true) })
24
+ @store = store
25
+ @interval = interval.to_i
26
+ @logger = logger
27
+ @refresher = refresher
28
+ @loaded = nil
29
+ end
30
+
31
+ # --- RubyLLM model_registry_store -------------------------------------
32
+
33
+ def read
34
+ raw = @store.get(SCOPE, "catalog") or return []
35
+ RubyLLM::Models::Registry.models_from_data(JSON.parse(raw, symbolize_names: true))
36
+ end
37
+
38
+ # RubyLLM hands a Models registry; stored as JSON so RubyLLM's own reader parses it back.
39
+ def write(registry)
40
+ models = registry.respond_to?(:all) ? registry.all : Array(registry)
41
+ @store.transaction do
42
+ @store.set(SCOPE, "catalog", JSON.generate(models.map(&:to_h)))
43
+ @store.set(SCOPE, "meta", { "refreshed_at" => Time.now.utc.iso8601(6), "models" => models.size })
44
+ end
45
+ end
46
+
47
+ def description = "Insika store (#{SCOPE})"
48
+
49
+ # --- Tick duty ---------------------------------------------------------
50
+
51
+ # -> { refreshed: true, models: n } | { reloaded: true } | nil (nothing to do)
52
+ def run(now: Time.now.utc)
53
+ return nil unless @interval.positive?
54
+
55
+ install
56
+ return refresh if claim(now)
57
+
58
+ reload
59
+ end
60
+
61
+ def refreshed_at = @store.get(SCOPE, "meta")&.fetch("refreshed_at", nil)
62
+
63
+ private
64
+
65
+ # RubyLLM reads and refreshes through the store from here on; a worker that
66
+ # already loaded the bundled catalog switches on its next #reload.
67
+ def install
68
+ require "ruby_llm"
69
+ RubyLLM.config.model_registry_store = self unless RubyLLM.config.model_registry_store.equal?(self)
70
+ end
71
+
72
+ def refresh
73
+ @refresher.call
74
+ @loaded = refreshed_at
75
+ { refreshed: true, models: RubyLLM.models.all.size }
76
+ rescue StandardError => e
77
+ @logger&.warn("model registry refresh failed: #{e.class}: #{e.message}")
78
+ { refreshed: false, error: e.class.name }
79
+ end
80
+
81
+ def reload
82
+ stamp = refreshed_at
83
+ return nil if stamp.nil? || stamp == @loaded
84
+
85
+ RubyLLM.models.load_from_store
86
+ @loaded = stamp
87
+ { reloaded: true }
88
+ end
89
+
90
+ # One refresher per window across workers (Tick's claim discipline): due once
91
+ # `refreshed_at` is an interval old, and a failed attempt waits RETRY_AFTER.
92
+ def claim(now)
93
+ @store.transaction do
94
+ attempted = time(@store.get(SCOPE, "claim")&.fetch("attempted_at", nil))
95
+ refreshed = time(refreshed_at)
96
+ due = refreshed.nil? || now - refreshed >= @interval
97
+ next false unless due && (attempted.nil? || now - attempted >= RETRY_AFTER)
98
+
99
+ @store.set(SCOPE, "claim", { "attempted_at" => now.iso8601 })
100
+ true
101
+ end
102
+ end
103
+
104
+ def time(value)
105
+ value && Time.iso8601(value.to_s)
106
+ rescue ArgumentError
107
+ nil
108
+ end
109
+ end
110
+ end
@@ -164,7 +164,7 @@ module Insika
164
164
  out = {}
165
165
  params.each do |k, v|
166
166
  key = k.to_sym
167
- out[key] = v if %i[temperature max_tokens thinking].include?(key)
167
+ out[key] = v if %i[temperature max_tokens thinking provider_routing].include?(key)
168
168
  end
169
169
  out
170
170
  end
@@ -37,6 +37,7 @@ module Insika
37
37
  def apply_params(chat)
38
38
  p = params || {}
39
39
  apply(chat, :with_temperature, p[:temperature]) if numeric?(p[:temperature])
40
+ apply(chat, :with_max_output_tokens, p[:max_tokens]) if numeric?(p[:max_tokens])
40
41
  apply_payload_params(chat, p)
41
42
  apply_effort(chat, p[:thinking]) if present?(p[:thinking])
42
43
  chat
@@ -48,27 +49,22 @@ module Insika
48
49
  chat.public_send(method, value) if chat.respond_to?(method)
49
50
  end
50
51
 
51
- # The params that ride RubyLLM's `with_params` (deep-merged OVER the provider
52
- # payload, so they win over a provider default). ONE call on purpose:
53
- # `with_params` REPLACES the whole hash — a second call would silently drop
54
- # the first one's keys.
55
- #
56
- # max_tokens goes here because the gem has NO with_max_* setter: the chat's
57
- # fluent API covers temperature/thinking only, and Anthropic is the only
58
- # provider that emits a cap at all (from the model registry, 4096 by default).
59
- # `max_tokens` is the wire key for Anthropic and for every OpenAI-compatible
60
- # provider (DeepSeek included); OpenAI's o-series wants
61
- # `max_completion_tokens` instead — that per-provider mapping is not modelled
62
- # yet, and a raw provider key is not authorable (the profile whitelists
63
- # temperature/max_tokens/thinking).
52
+ # Provider-specific controls retain their existing wire shape. Token limits
53
+ # use RubyLLM's native setter so each protocol chooses its own wire key.
64
54
  def apply_payload_params(chat, p)
65
- return unless chat.respond_to?(:with_params)
55
+ return unless chat.respond_to?(:with_provider_options)
66
56
 
67
57
  payload = {}
68
- payload[:max_tokens] = p[:max_tokens] if numeric?(p[:max_tokens])
69
58
  toggle = thinking_toggle(p[:thinking])
70
59
  payload[:thinking] = { type: toggle } if toggle
71
- chat.with_params(**payload) unless payload.empty?
60
+ # WHICH upstream serves the model, for a gateway that has several. Authored
61
+ # as `provider_routing` and emitted under the wire key `provider` —
62
+ # OpenRouter's, the only shape that exists for this today; a provider
63
+ # without the field ignores an unknown body key. A raw provider key is
64
+ # not authorable, so the one
65
+ # capability gets a name of its own.
66
+ payload[:provider] = p[:provider_routing] if p[:provider_routing].is_a?(Hash)
67
+ chat.with_provider_options(payload) unless payload.empty?
72
68
  end
73
69
 
74
70
  # The resolved reasoning control (4-layer). Two axes folded into one field:
@@ -78,11 +74,8 @@ module Insika
78
74
  # Blank/nil never reaches here (apply_params guards on present?), so no config
79
75
  # anywhere = the provider's own default.
80
76
  #
81
- # ruby_llm 1.16's with_thinking only EMITS reasoning_effort and cannot toggle
82
- # on/off; the on/off ride on with_params (deep-merged over the payload). The
83
- # thinking:{type:} shape is DeepSeek's OpenAI-compat contract — gated to it
84
- # (nil provider = the platform default, DeepSeek here); other providers get
85
- # effort-only until their toggle wire is mapped (e.g. Anthropic output_config).
77
+ # Native on/off requires model-registry controls, which custom DeepSeek ids
78
+ # may not have. Keep the explicit DeepSeek toggle valid for those models.
86
79
  def thinking_toggle(value)
87
80
  return nil unless present?(value) && reasoning_toggle_supported?
88
81
 
@@ -20,19 +20,30 @@ module Insika
20
20
  # -> ModelVisible. `chat` is the RubyLLM chat at the boundary (instructions,
21
21
  # tools, messages are exactly the three parts the provider serializes).
22
22
  def self.capture(chat)
23
+ messages = chat.respond_to?(:messages) ? Array(chat.messages) : []
24
+ tools = chat.respond_to?(:tools) ? chat.tools : []
23
25
  new(
24
- instructions: chat.respond_to?(:instructions) ? chat.instructions : nil,
25
- tools: chat.respond_to?(:tools) ? Array(chat.tools).map { |t| tool_schema(t) } : [],
26
- messages: chat.respond_to?(:messages) ? Array(chat.messages) : []
26
+ instructions: instructions_of(chat, messages),
27
+ tools: (tools.is_a?(Hash) ? tools.values : Array(tools)).map { |t| tool_schema(t) },
28
+ messages: messages
27
29
  )
28
30
  end
29
31
 
30
- # -> Hash: the JSON-safe schema of ONE tool. Reads `parameters` then
32
+ # -> String | nil: the system text the chat sends (the instructions reader, else
33
+ # its system messages). Also what Safety::PromptEcho compares a reply against.
34
+ def self.instructions_of(chat, messages = chat.respond_to?(:messages) ? Array(chat.messages) : [])
35
+ return chat.instructions if chat.respond_to?(:instructions)
36
+
37
+ system = messages.select { |m| m.respond_to?(:role) && m.role.to_s == "system" }
38
+ Coercion.presence(system.map(&:content).join("\n\n"))
39
+ end
40
+
41
+ # -> Hash: the JSON-safe schema of ONE tool. Reads `parameters_schema` then
31
42
  # `schema` (the two reader shapes the house FakeChat and the gem's tools
32
43
  # answer); a tool answering none degrades to nil, never raises.
33
44
  def self.tool_schema(tool)
34
- params = if tool.respond_to?(:parameters)
35
- tool.parameters
45
+ params = if tool.respond_to?(:parameters_schema)
46
+ tool.parameters_schema
36
47
  elsif tool.respond_to?(:schema)
37
48
  tool.schema
38
49
  end
@@ -55,6 +55,7 @@ module Insika
55
55
  "embedding" => "docs/EMBEDDING.md",
56
56
  "sandbox" => "docs/SANDBOX.md",
57
57
  "benchmark" => "docs/BENCHMARK.md",
58
+ "rubyllm-2-migration" => "docs/RUBYLLM_2_MIGRATION.md",
58
59
  "observability" => "docs/OBSERVABILITY.md",
59
60
  "schedules" => "docs/SCHEDULING.md",
60
61
  "artifacts" => "docs/ARTIFACTS.md",
@@ -43,7 +43,7 @@ module Insika
43
43
  # the no-tenant records must pass `tenant: ""` explicitly (the FOLD uses
44
44
  # `for_pair`, never this nil-means-everything trap).
45
45
  def all(tenant: nil, agent: nil)
46
- records = @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
46
+ records = @store.entries(SCOPE).filter_map { |_k, rec| to_record(rec) }
47
47
  records.select! { |r| r.tenant == tenant } if tenant
48
48
  records.select! { |r| r.agent == agent } if agent
49
49
  records.sort_by(&:at).reverse
@@ -27,8 +27,10 @@ module Insika
27
27
  # are "data-defined" or MCP-discovered.
28
28
  class OverlayToolRegistry
29
29
  def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil,
30
- mcp_registry: nil)
30
+ mcp_registry: nil, recheck: 2, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
31
31
  @base = base
32
+ @recheck = recheck
33
+ @clock = clock
32
34
  @tool_store = tool_store
33
35
  @http = http
34
36
  @egress = egress
@@ -70,16 +72,37 @@ module Insika
70
72
  # Atomic swap of the dynamic index (after writing/removing a data-tool).
71
73
  def reload
72
74
  @dynamic = build_dynamic
75
+ @token = @tool_store.version_token
76
+ @checked_at = @clock.call
73
77
  self
74
78
  end
75
79
 
80
+ # Changes when the data-tools were rebuilt; a catalog over this registry
81
+ # compares it to know its own index is stale.
82
+ def generation
83
+ dynamic
84
+ @token
85
+ end
86
+
76
87
  # Is it a CODE tool (base)? Used by the authoring Command's validation.
77
88
  def code_tool?(name) = @base.names.include?(name.to_s)
78
89
 
79
90
  private
80
91
 
92
+ # Another worker may have written a data-tool (import, Studio edit): at most
93
+ # every `recheck` seconds, compare the config version token and rebuild when it
94
+ # moved. The token moves on any config write, so a rebuild may find nothing new.
81
95
  def dynamic
82
- @dynamic ||= build_dynamic
96
+ reload if @dynamic.nil? || stale?
97
+ @dynamic
98
+ end
99
+
100
+ def stale?
101
+ now = @clock.call
102
+ return false if now - @checked_at < @recheck
103
+
104
+ @checked_at = now
105
+ @tool_store.version_token != @token
83
106
  end
84
107
 
85
108
  # No I/O (McpToolRegistry#entries only reads McpStore#tools_cache) ->
@@ -112,6 +135,11 @@ module Insika
112
135
  # Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
113
136
  # out of insika.rb load-time, loaded on the 1st instance (turn time).
114
137
  def build_tool(definition)
138
+ if definition.presentation?
139
+ require_relative "tools/present"
140
+ return Insika::Tools::Present.new(definition: definition, event_stream: @event_stream)
141
+ end
142
+
115
143
  require_relative "tools/data_defined_tool"
116
144
  Insika::Tools::DataDefinedTool.new(
117
145
  definition: definition, http: @http, egress: @egress,