insika 0.9.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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -1,43 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "ruby_llm"
4
5
 
5
6
  module Insika
6
- # One live MCP tool. `name`/`description`/`params_schema`
7
+ # One live MCP tool. `name`/`description`/`parameters_schema`
7
8
  # come from the CACHED descriptor (McpStore#tools_cache — no I/O to build
8
9
  # this instance, same cost as any other tool the registry hands out).
9
10
  # `#execute` is the only thing that touches the network, calling through
10
- # `client_for` (Insika::McpToolRegistry's memoized, started client — this
11
- # class never builds or starts one itself) into the gem's own
12
- # `RubyLLM::MCP::Tool#execute`, which already unwraps `content[].text` and
11
+ # `client_for` (Insika::McpToolRegistry's memoized, lazy, per-conversation client) into
12
+ # `RubyLLM::MCP::Tool#call`, which already unwraps `content[].text` and
13
13
  # turns `isError` into `{error:}`.
14
14
  #
15
15
  # Like every tool in this codebase, `#execute` NEVER raises: a connection
16
16
  # or protocol failure becomes `{error:}`, the model's normal failure path
17
17
  # (see Tools::DataDefinedTool's own rule).
18
18
  class McpLiveTool < RubyLLM::Tool
19
- def initialize(instance_name:, tool:, client_for:)
19
+ # `name:` is what the model calls (the registry's instance-qualified name);
20
+ # the server is still asked for its own tool name.
21
+ def initialize(instance_name:, tool:, client_for:, overrides: {}, name: tool["name"])
20
22
  @instance_name = instance_name
21
23
  @tool = tool
24
+ @name = name
22
25
  @client_for = client_for
26
+ @evidence = overrides["evidence"] && Insika::Evidence::Spec.parse(overrides["evidence"])
27
+ @requires_evidence = Insika::ToolDefinition.normalize_requires_evidence(
28
+ overrides["requires_evidence"], top_level_params
29
+ )
23
30
  super()
24
31
  end
25
32
 
26
- def name = @tool["name"]
33
+ def name = @name
27
34
  def description = @tool["description"].to_s
28
35
 
29
- def params_schema
36
+ # The two questions the envelope asks any tool: what of this result is
37
+ # evidence, and which of these parameters may only carry an id some tool
38
+ # already returned. A server cannot answer either — it does not know what the
39
+ # deployment trusts — so they come from the instance's own configuration and
40
+ # are nil for every tool nobody configured, which is what they were before.
41
+ attr_reader :evidence, :requires_evidence
42
+
43
+ def parameters_schema
30
44
  schema = @tool["inputSchema"]
31
45
  schema.nil? || schema.empty? ? { "type" => "object", "properties" => {} } : schema
32
46
  end
33
47
 
48
+ # The conversation this instance serves, deposited by ToolAssembly. An MCP
49
+ # server keeps state per MCP session (a target store, a pending
50
+ # confirmation token), so each conversation gets its own client.
51
+ def turn_context=(ctx)
52
+ @session_id = ctx && (ctx[:session_id] || ctx["session_id"])
53
+ end
54
+
34
55
  def execute(**params)
35
- live = @client_for.call.tool(@tool["name"])
56
+ live = @client_for.call(@session_id).tools.find { |tool| tool.name == @tool["name"] }
36
57
  raise Insika::NotFoundError, "tool '#{@tool["name"]}' no longer offered" if live.nil?
37
58
 
38
- live.execute(**params)
59
+ result = live.call(**params)
60
+ # Hand the envelope what a chat would send the model (text, JSON of the
61
+ # structured content, or text plus attachments), not the gem's Result.
62
+ parsed(result.is_a?(RubyLLM::MCP::Result) ? result.content : result)
39
63
  rescue StandardError => e
40
64
  { error: "MCP instance '#{@instance_name}' tool '#{@tool["name"]}' failed: #{e.message}" }
41
65
  end
66
+
67
+ private
68
+
69
+ # An MCP result arrives as a String, text plus attachments (an Array), or
70
+ # {error:}. Only a tool someone declared evidence for is
71
+ # parsed into the object the extractor can dig into; everywhere else the result
72
+ # reaches the model exactly as it did before.
73
+ def parsed(result)
74
+ return result unless @evidence && result.is_a?(String)
75
+
76
+ object = begin
77
+ JSON.parse(result)
78
+ rescue JSON::ParserError
79
+ nil
80
+ end
81
+ object.is_a?(Hash) ? object : result
82
+ end
83
+
84
+ def top_level_params
85
+ properties = parameters_schema["properties"] || parameters_schema[:properties] || {}
86
+ properties.keys.map(&:to_s)
87
+ end
42
88
  end
43
89
  end
@@ -94,12 +94,43 @@ module Insika
94
94
  "headers" => reconcile_hash(h[:headers], existing && existing["headers"]),
95
95
  # editing an instance's connection details doesn't change its discovered
96
96
  # tools -> preserved across upsert; only set_tools_cache writes it.
97
- "tools_cache" => (existing && existing["tools_cache"]) || []
97
+ "tools_cache" => (existing && existing["tools_cache"]) || [],
98
+ # WHAT EACH TOOL MEANS to the engine: which of its results are evidence,
99
+ # and which of its parameters may only carry an id some tool returned.
100
+ # A server describes what its tools DO; only the deployment can say what
101
+ # they are worth trusting with. An edit that does not mention them keeps
102
+ # what is there — the same rule `env` and `headers` follow.
103
+ "tool_overrides" => (h.key?(:tools) ? tool_overrides(h[:tools]) : nil) ||
104
+ (existing && existing["tool_overrides"]) || {}
98
105
  }
99
106
  @cs.put(SCOPE, name, record)
100
107
  mask(record)
101
108
  end
102
109
 
110
+ # { "add_to_cart" => { "requires_evidence" => ["product_id"] },
111
+ # "search_products" => { "evidence" => { "kind" => "products", … } } }
112
+ #
113
+ # Refused HERE, never at the turn: an `evidence:` typo that survived to turn
114
+ # time would extract nothing and the gate below it would block every write.
115
+ def tool_overrides(raw)
116
+ return nil if raw.nil?
117
+ raise Insika::ValidationError, "mcp tools must be a mapping of tool name -> overrides" unless raw.is_a?(Hash)
118
+
119
+ Coercion.deep_stringify(raw).to_h do |tool, over|
120
+ raise Insika::ValidationError, "mcp tools['#{tool}'] must be a mapping" unless over.is_a?(Hash)
121
+
122
+ unknown = over.keys - %w[evidence requires_evidence]
123
+ unless unknown.empty?
124
+ raise Insika::ValidationError,
125
+ "mcp tools['#{tool}']: unknown key(s) #{unknown.join(", ")} — known: evidence, requires_evidence"
126
+ end
127
+
128
+ params = Array(over["requires_evidence"]).map(&:to_s).reject(&:empty?)
129
+ [tool.to_s, { "evidence" => over["evidence"] && Insika::Evidence::Spec.parse(over["evidence"]).to_h,
130
+ "requires_evidence" => (params unless params.empty?) }.compact]
131
+ end
132
+ end
133
+
103
134
  # -> bool (did it exist?).
104
135
  def delete(name) = @cs.delete(SCOPE, name.to_s)
105
136
 
@@ -11,14 +11,30 @@ module Insika
11
11
  # `refresh` is the ONLY thing that talks to a server ahead of time: it
12
12
  # connects, lists live, and writes the result back to tools_cache (display/
13
13
  # doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
14
- # always goes through the live, memoized client (and RubyLLM::MCP::Client
14
+ # always goes through the live, memoized client (and RubyLLM::MCP
15
15
  # does its own real `tools/list` on ITS first use per instance, regardless
16
16
  # of whether `refresh` ever ran).
17
17
  class McpToolRegistry
18
- def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for))
18
+ # Providers reject a tool name longer than this.
19
+ MAX_TOOL_NAME = 64
20
+
21
+ # The name the model and the agent's allowlist see: the instance, then the
22
+ # server's tool. Two servers offering the same tool (a store's prod and
23
+ # staging) stay two tools, each reaching its own instance.
24
+ def self.tool_name(instance, tool)
25
+ "#{instance.to_s.downcase.gsub(/[^a-z0-9]+/, '_')}__#{tool}"
26
+ end
27
+
28
+ # Live clients kept per process. Past this, the least recently used one is
29
+ # closed; its conversation reconnects on its next call, as a NEW MCP session.
30
+ # ponytail: count cap, not idle TTL — add a TTL if idle sockets pile up.
31
+ MAX_CLIENTS = 200
32
+
33
+ def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for), max_clients: MAX_CLIENTS)
19
34
  @mcp_store = mcp_store
20
35
  @client_factory = client_factory
21
- @clients = {}
36
+ @max_clients = max_clients
37
+ @clients = {} # [instance, conversation] -> client, oldest use first
22
38
  @mutex = Mutex.new
23
39
  end
24
40
 
@@ -29,7 +45,7 @@ module Insika
29
45
 
30
46
  # Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
31
47
  # ALWAYS drops any memoized client first and rebuilds from the current
32
- # record: a stdio process can be `alive?` (still running) yet permanently
48
+ # record: a stdio process can still be running yet permanently
33
49
  # broken (e.g. wrong transport args — see grafana-stg gotcha), and an
34
50
  # edited command/url/env must take effect without a process restart —
35
51
  # there's no SSH into a Railway dyno to do that by hand.
@@ -43,7 +59,10 @@ module Insika
43
59
 
44
60
  evict(record["name"])
45
61
  client = client_for(record)
46
- discovered = client.tools.map { |t| { "name" => t.name, "description" => t.description, "inputSchema" => t.params_schema } }
62
+ discovered = client.tools.map do |tool|
63
+ { "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema,
64
+ "annotations" => { "readOnlyHint" => tool.read_only? } }
65
+ end
47
66
  @mcp_store.set_tools_cache(name, discovered)
48
67
  discovered
49
68
  end
@@ -55,24 +74,30 @@ module Insika
55
74
  # NEXT tool call or refresh, no process restart required (there's no way
56
75
  # to restart a process by hand on a Railway dyno).
57
76
  def evict(name)
58
- client = @mutex.synchronize { @clients.delete(name.to_s) }
59
- client&.stop
60
- rescue StandardError
61
- nil # best-effort teardown of a possibly already-dead process
77
+ gone = @mutex.synchronize do
78
+ keys = @clients.keys.select { |instance, _| instance == name.to_s }
79
+ keys.map { |key| @clients.delete(key) }
80
+ end
81
+ gone.each { |client| close_quietly(client) }
62
82
  end
63
83
 
64
84
  private
65
85
 
66
86
  def entries_for(record)
67
- Array(record["tools_cache"]).map { |tool| entry_for(record, tool) }
87
+ Array(record["tools_cache"]).filter_map do |tool|
88
+ name = self.class.tool_name(record["name"], tool["name"])
89
+ next entry_for(record, tool, name) if name.length <= MAX_TOOL_NAME
90
+
91
+ warn "[mcp] skipping '#{name}': longer than #{MAX_TOOL_NAME} characters; shorten the instance name"
92
+ end
68
93
  end
69
94
 
70
- def entry_for(record, tool)
95
+ def entry_for(record, tool, name)
71
96
  instance = record["name"]
72
97
  Insika::Registry::Entry.new(
73
- name: tool["name"], plugin: "mcp:#{instance}",
98
+ name: name, plugin: "mcp:#{instance}",
74
99
  metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
75
- factory: -> { build_tool(record, tool) }
100
+ factory: -> { build_tool(record, tool, name) }
76
101
  )
77
102
  end
78
103
 
@@ -80,28 +105,46 @@ module Insika
80
105
  # (`annotations.readOnlyHint`): a write is never re-run on resume and runs
81
106
  # serially within the session; a declared read keeps `tool_concurrency`.
82
107
  def read_only?(tool)
83
- Coercion.truthy?(tool.dig("annotations", "readOnlyHint"))
108
+ tool.dig("annotations", "readOnlyHint") == true
84
109
  end
85
110
 
86
111
  # Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
87
112
  # of insika.rb load-time, loaded on the 1st instance (turn time), same
88
113
  # discipline as OverlayToolRegistry#build_tool for data-tools.
89
- def build_tool(record, tool)
114
+ def build_tool(record, tool, name)
90
115
  require_relative "mcp_live_tool"
91
- Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, client_for: -> { client_for(record) })
116
+ Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, name: name,
117
+ overrides: record.dig("tool_overrides", tool["name"]) || {},
118
+ client_for: ->(session_id = nil) { client_for(record, session_id) })
92
119
  end
93
120
 
94
- # A started, MEMOIZED client for `record` — one real connection per
95
- # instance name, reused across calls/turns. Raises on a gated/unreachable
96
- # instance; only called from `refresh` and from a running McpLiveTool's
97
- # `#execute` (which rescues) — never from `entries`/`build_tool`, so a
98
- # downed server never breaks turn ASSEMBLY, only that tool's own call.
99
- def client_for(record)
100
- @mutex.synchronize do
101
- client = (@clients[record["name"]] ||= @client_factory.call(record))
102
- client.start unless client.alive?
103
- client
121
+ # A lazy, MEMOIZED client for `record` — one per (instance, conversation),
122
+ # reused across that conversation's turns. The server ties its state to the
123
+ # MCP session (Mcp-Session-Id): a client shared by every conversation leaked
124
+ # one chat's target store and confirmation tokens into another. A stdio
125
+ # instance stays one shared process — a process per chat is too costly.
126
+ # Raises on a gated/unreachable instance; only called from `refresh` and
127
+ # from a running McpLiveTool's `#execute` (which rescues) — never from
128
+ # `entries`/`build_tool`, so a downed server never breaks turn ASSEMBLY,
129
+ # only that tool's own call.
130
+ def client_for(record, session_id = nil)
131
+ session_id = nil if record["transport"].to_s == "stdio"
132
+ key = [record["name"], session_id&.to_s]
133
+ stale = nil
134
+ client = @mutex.synchronize do
135
+ found = @clients.delete(key) || @client_factory.call(record)
136
+ @clients[key] = found # re-insert: most recently used goes last
137
+ stale = @clients.shift.last if @clients.size > @max_clients
138
+ found
104
139
  end
140
+ close_quietly(stale) if stale
141
+ client
142
+ end
143
+
144
+ def close_quietly(client)
145
+ client.close
146
+ rescue StandardError
147
+ nil # best-effort teardown of a possibly already-dead process
105
148
  end
106
149
  end
107
150
  end
data/lib/insika/media.rb CHANGED
@@ -93,9 +93,9 @@ module Insika
93
93
  # when the turn first carries audio. `stt_prompt` is the Whisper-family
94
94
  # vocabulary hint (product names, brand terms) — OPERATOR config
95
95
  # (agent profile / deployment env), never customer input.
96
- def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
96
+ def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil, context: nil)
97
97
  lambda do |url|
98
- fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
98
+ fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt, context: context)
99
99
  end
100
100
  end
101
101
 
@@ -110,7 +110,7 @@ module Insika
110
110
  # RubyLLM raises ArgumentError when it's true without an explicit
111
111
  # `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
112
112
  # is a bare ref like `utility_model` elsewhere — the registry resolves it.
113
- def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
113
+ def self.fetch_and_transcribe(url, model:, language:, prompt: nil, context: nil)
114
114
  require "net/http"
115
115
  require "uri"
116
116
  require "ruby_llm" # lazy — the core loads without it (load-guard)
@@ -120,6 +120,7 @@ module Insika
120
120
  options = { model: model }
121
121
  options[:language] = language if language
122
122
  options[:prompt] = prompt if prompt
123
+ options[:context] = context if context
123
124
  Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
124
125
  file.binmode
125
126
  file.write(bytes)
@@ -128,6 +129,23 @@ module Insika
128
129
  end
129
130
  end
130
131
 
132
+ # Durable attachment bytes from a checkpoint; hydrate only at the gem boundary.
133
+ def self.hydrate_attachment(value)
134
+ return value unless value.is_a?(Hash)
135
+ require 'base64'
136
+ require 'stringio'
137
+ require 'ruby_llm'
138
+ unless (value.keys - %w[base64 filename]).empty? && value['base64'].is_a?(String) && value['filename'].is_a?(String)
139
+ raise Insika::MediaError, 'invalid stored attachment'
140
+ end
141
+ raise Insika::MediaError, 'stored attachment too large' if value['base64'].bytesize > 14*1024*1024
142
+ bytes = Base64.strict_decode64(value['base64'])
143
+ raise Insika::MediaError, 'stored attachment too large' if bytes.bytesize > MAX_DOCUMENT_BYTES
144
+ RubyLLM::Attachment.new(StringIO.new(bytes), filename: value['filename'])
145
+ rescue ArgumentError
146
+ raise Insika::MediaError, 'invalid stored attachment'
147
+ end
148
+
131
149
  # An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
132
150
  # guarded, size-capped — the `media_attachment` recipe). Shared by the
133
151
  # Executor (inbound image/document parts) and `Output.generate_image`
@@ -201,8 +219,7 @@ module Insika
201
219
  # behind lazy requires:
202
220
  # · image — RubyLLM.paint (the gem has vision AND painting), billed
203
221
  # tokens merged into the turn's usage like any ask;
204
- # · tts — RubyLLM still has NO speech API (as of 1.16.0), so the default
205
- # is a thin POST to the OpenAI-compatible `<base>/audio/speech`
222
+ # · tts — a capped POST to the OpenAI-compatible `<base>/audio/speech`
206
223
  # endpoint (base + key from the provider config the chat uses — a
207
224
  # deployment pointing OpenAI at a gateway keeps TTS pointing there).
208
225
  # OpenAI's speech API reports no token usage; the part carries the
@@ -256,8 +273,8 @@ module Insika
256
273
 
257
274
  enforce_embedded_size!(data, "generated image")
258
275
  mime = image.respond_to?(:mime_type) ? image.mime_type : nil
259
- model_id = image.respond_to?(:model_id) ? image.model_id : nil
260
- usage = image.respond_to?(:usage) ? token_usage(image.usage) : {}
276
+ model_id = image.model
277
+ usage = { input_tokens: image.tokens.input, output_tokens: image.tokens.output }.compact
261
278
  part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
262
279
  "base64" => data, "model" => presence(model_id) }
263
280
  [part.compact, usage]
@@ -292,20 +309,24 @@ module Insika
292
309
  req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
293
310
  response_format: format)
294
311
  opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
312
+ # RubyLLM 2.0 buffers non-audio/error responses without yielding chunks.
313
+ # Keep this bounded transport until native speech caps every response.
295
314
  bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
296
- resp = http.request(req)
297
- raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
298
-
299
- # stream into the cap — a rogue/broken endpoint must not grow the
300
- # process past MAX_EMBEDDED_BYTES before the refusal.
301
315
  buf = +"".b
302
- resp.read_body do |chunk|
303
- buf << chunk
304
- break if buf.bytesize > MAX_EMBEDDED_BYTES
316
+ http.request(req) do |resp|
317
+ raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
318
+
319
+ resp.read_body do |chunk|
320
+ size = buf.bytesize + chunk.bytesize
321
+ if size > MAX_EMBEDDED_BYTES
322
+ raise Insika::MediaError,
323
+ "synthesized speech too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
324
+ end
325
+ buf << chunk
326
+ end
305
327
  end
306
328
  buf
307
329
  end
308
- enforce_embedded_size!(bytes, "synthesized speech")
309
330
  part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
310
331
  [part.compact, {}]
311
332
  rescue URI::InvalidURIError
@@ -352,14 +373,6 @@ module Insika
352
373
  Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
353
374
  end
354
375
 
355
- def token_usage(raw)
356
- usage = raw.is_a?(Hash) ? raw : {}
357
- {
358
- input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
359
- output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
360
- }.compact
361
- end
362
-
363
376
  def mime_for(format)
364
377
  { "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
365
378
  "wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
@@ -379,4 +392,4 @@ module Insika
379
392
  end
380
393
  end
381
394
  end
382
- end
395
+ end
@@ -155,9 +155,10 @@ module Insika
155
155
  # "memory:chat:<tenant>:<id>"). The scope enumeration is the Store's — no
156
156
  # session-derived list, so a customer cell whose session was already
157
157
  # deleted is still purged. ONE transaction for the whole tenant (the repo
158
- # rule): the scope enumeration and every delete see the same snapshot, so a
159
- # customer cell born mid-purge cannot slip through a deletion that reported
160
- # success. -> count of records purged.
158
+ # rule): the purge commits or fails whole. On SQLite, whose transactions
159
+ # run one at a time, a cell cannot be written mid-purge at all; on a backend
160
+ # with concurrent writers a cell written while the purge runs lands after it
161
+ # (a later write, which a later purge removes). -> count of records purged.
161
162
  def purge_tenant(tenant)
162
163
  cell = scope_for(tenant)
163
164
  @store.transaction do
@@ -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