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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "logger"
4
+
3
5
  require "async"
4
6
  require_relative "telemetry/pricing"
5
7
  require_relative "telemetry/recorder"
@@ -15,7 +17,7 @@ module Insika
15
17
  # (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`). The destination/protocol
16
18
  # follows the OTEL SDK's default config (env) — SigNoz/Tempo/Jaeger/Collector. The
17
19
  # OTEL_* keys are the OpenTelemetry SDK's own env and stay verbatim; only our opt-in
18
- # toggle is renamed (INSIKA_OTEL, with the HARNESS_OTEL alias still read).
20
+ # toggle is INSIKA_OTEL.
19
21
  #
20
22
  # Metrics ride the same switch and the SAME standard env: the SDK
21
23
  # registers a periodic metric reader when the OTLP metrics exporter is loadable,
@@ -39,6 +41,7 @@ module Insika
39
41
  require "opentelemetry/sdk"
40
42
  require "opentelemetry/exporter/otlp"
41
43
  load_metrics_sdk
44
+ OpenTelemetry.logger = sdk_logger
42
45
  unless @configured
43
46
  OpenTelemetry::SDK.configure { |c| c.service_name = service_name }
44
47
  @configured = true
@@ -49,6 +52,13 @@ module Insika
49
52
  meter: meter, pricing: pricing(env))
50
53
  end
51
54
 
55
+ # The SDK's default logger writes to $stdout, which Ruby buffers when it is a
56
+ # pipe (Falcon workers, a container log): a rejected export (404 from an
57
+ # ingest that wants a header) never reached the log. $stderr is unbuffered.
58
+ def sdk_logger(io = $stderr)
59
+ Logger.new(io, progname: "otel", level: ENV.fetch("OTEL_LOG_LEVEL", "warn"))
60
+ end
61
+
52
62
  # Did `setup` wire the metric instruments too (SDK present, at least one reader)?
53
63
  # Only meaningful after `setup` — it exists for the boot banners.
54
64
  def metrics? = @metrics == true
@@ -68,7 +78,7 @@ module Insika
68
78
  # Operator-declared rates (USD per million tokens) as JSON in
69
79
  # INSIKA_MODEL_PRICING. Unset/malformed -> an empty table -> no cost is reported.
70
80
  def pricing(env = ENV)
71
- table = Pricing.parse(Insika::EnvSchema.read("INSIKA_MODEL_PRICING", env))
81
+ table = Pricing.from_env(env)
72
82
  table.empty? ? nil : table
73
83
  end
74
84
 
@@ -20,6 +20,19 @@ Then open `/studio`, log in with the printed token, and either wait for the
20
20
  schedule (22:00 America/Sao_Paulo) or send a message to the "reporter" agent
21
21
  in the Playground — the artifact lands on the Artifacts tab either way.
22
22
 
23
+ ## Report design
24
+
25
+ The skill asks for a dated executive takeaway, consistent KPI cards, warm neutrals
26
+ with one amber accent, labeled charts, sources and limitations. Reports adapt to
27
+ phone widths and the system light/dark preference. Native Chart/Table radio choices,
28
+ CSS hover/focus highlights and expandable details work without JavaScript. Exact
29
+ values remain accessible on touch and in a table; focus is visible, 140ms transitions
30
+ respect reduced motion, and print includes the full table and details.
31
+
32
+ See the [fictional-data example](https://github.com/guizaols/insika/blob/main/examples/scheduled-report/interactive-report.html).
33
+ These interactions explore a saved snapshot, not live data. This guidance applies
34
+ to newly generated reports; existing artifacts stay unchanged.
35
+
23
36
  ## What's real here
24
37
 
25
38
  - The **schedule** is a live engine feature: the tick actually fires this
@@ -47,21 +47,32 @@ reporter = Insika.agent("reporter") do
47
47
  overrides: { turn_timeout: 600, max_tool_calls: 120 }
48
48
 
49
49
  # The generic, store-free skill: how a report is BUILT — the inline-SVG
50
- # pattern (palette, table, pure-SVG bars, light/dark). No store ids, no
50
+ # pattern (palette, table, native controls, light/dark). No store ids, no
51
51
  # queries — that half belongs in a merchant's pack, not here.
52
52
  skill "daily-digest",
53
53
  description: "How to build the daily sales digest as a self-contained HTML report",
54
54
  instructions: <<~MD
55
- Build the digest as ONE self-contained HTML page:
56
- - a <style> block with a light palette (e.g. #f8fafc bg, #0f172a text,
57
- #6366f1 accent), plus a prefers-color-scheme: dark override;
58
- - one <table> of the numbers (th/tbody, right-aligned amounts);
59
- - one inline SVG bar chart (no <script>, no <img>, no <iframe>,
60
- no external fonts or fetches — the report is served with
61
- default-src 'none', so nothing external can load);
62
- - a title and the run date.
63
- Keep it readable on a phone. The report is the deliverable; the
64
- channel message is just the link to it.
55
+ Build ONE self-contained executive HTML report with a <style> block:
56
+ - Lead with a clear title, period, generation date and concise,
57
+ evidence-backed takeaway. Name sources and limitations. Use only
58
+ supplied numbers; show missing data honestly and label demo data.
59
+ - Use warm neutrals and one amber accent (or the merchant's accent),
60
+ consistent type, spacing and KPI cards, and system dark mode.
61
+ - Include a responsive inline SVG or HTML bar chart with clear labels,
62
+ units and exact values, plus a captioned table with column headers.
63
+ - Offer Chart/Table through labeled, focusable native radios grouped
64
+ in a fieldset with a legend; use CSS :checked, not ARIA tabs.
65
+ - Use hover/focus tooltips in reserved space; keep them visible when
66
+ hovered. Add CSS highlights, a visible 2px focus outline, and
67
+ details/summary for supporting evidence. Exact values
68
+ must remain available on touch without hovering.
69
+ - Use 140ms color/opacity transitions; disable them for reduced motion.
70
+ No decorative animation. Print the full table and supporting details
71
+ regardless of the selected view or collapsed state; hide controls.
72
+ - This is a dated data snapshot, not a live feed. No scripts, event
73
+ handlers, forms, iframe, external fonts or fetches. The report's
74
+ default-src 'none' policy blocks external resources.
75
+ Keep it readable on a phone. The channel message is just the link.
65
76
  MD
66
77
  end
67
78
 
@@ -117,6 +117,12 @@ RSpec.shared_examples "an Insika store" do
117
117
  expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
118
118
  end
119
119
 
120
+ it "keeps the prefix boundary exact, ASCII or not" do
121
+ %w[task task: task:1 task:é task; tasks sessão:1 sessão:2 sessãoX run9 run9a run: taz taz1 ta{].each { |k| store.set("s", k, 1) }
122
+ expect([store.list("s", "task:"), store.list("s", "sessão:"), store.list("s", "run9"), store.list("s", "taz")])
123
+ .to eq([%w[task: task:1 task:é], %w[sessão:1 sessão:2], %w[run9 run9a], %w[taz taz1]])
124
+ end
125
+
120
126
  it " returns [] for an empty scope" do #
121
127
  expect(store.list("s")).to eq([])
122
128
  end
@@ -128,6 +134,65 @@ RSpec.shared_examples "an Insika store" do
128
134
  end
129
135
  end
130
136
 
137
+ # Bulk read: what a caller that lists keys and reads each one needs, in one
138
+ # call. A backend may answer it in one query; the default is list + get.
139
+ describe "#entries" do
140
+ it "returns [key, value] pairs sorted by key, values deserialized" do
141
+ store.set("s", "b", { "n" => 2 })
142
+ store.set("s", "a", [1])
143
+ store.set("other", "a", "x")
144
+ expect(store.entries("s")).to eq([["a", [1]], ["b", { "n" => 2 }]])
145
+ end
146
+
147
+ it "filters by prefix with start_with?, ASCII or not" do
148
+ store.set("s", "ab", 1)
149
+ store.set("s", "abc", 2)
150
+ store.set("s", "b", 3)
151
+ store.set("s", "é1", 4)
152
+ expect(store.entries("s", "ab")).to eq([["ab", 1], ["abc", 2]])
153
+ expect(store.entries("s", "é")).to eq([["é1", 4]])
154
+ end
155
+
156
+ it "returns [] for an empty scope" do
157
+ expect(store.entries("nothing")).to eq([])
158
+ end
159
+ end
160
+
161
+ # The last-written keys, newest first: what a "recent" list needs without
162
+ # reading the whole scope.
163
+ describe "#recent" do
164
+ it "returns the most recently written pairs first, capped at limit" do
165
+ store.set("s", "a", { "n" => 1 })
166
+ store.set("s", "b", { "n" => 2 })
167
+ store.set("s", "c", { "n" => 3 })
168
+ store.set("s", "a", { "n" => 4 }) # rewritten: now the newest
169
+ expect(store.recent("s", nil, 2)).to eq([["a", { "n" => 4 }], ["c", { "n" => 3 }]])
170
+ end
171
+
172
+ it "skips the newest `offset` pairs (the next page)" do
173
+ %w[a b c d e].each_with_index { |k, i| store.set("s", "p:#{k}", i) }
174
+ expect(store.recent("s", "p:", 2, 0).map(&:first)).to eq(%w[p:e p:d])
175
+ expect(store.recent("s", "p:", 2, 2).map(&:first)).to eq(%w[p:c p:b])
176
+ expect(store.recent("s", "p:", 2, 4).map(&:first)).to eq(%w[p:a])
177
+ expect(store.recent("s", nil, 10, 5)).to eq([])
178
+ end
179
+
180
+ it "an amended key keeps its place; an absent one is written" do
181
+ store.set("s", "a", { "n" => 1 })
182
+ store.set("s", "b", { "n" => 2 })
183
+ store.amend("s", "a", { "n" => 3 })
184
+ store.amend("s", "c", { "n" => 4 })
185
+ expect(store.recent("s", nil, 3)).to eq([["c", { "n" => 4 }], ["b", { "n" => 2 }], ["a", { "n" => 3 }]])
186
+ end
187
+
188
+ it "keeps to the scope and the prefix" do
189
+ store.set("s", "x:1", 1)
190
+ store.set("other", "x:2", 2)
191
+ store.set("s", "y:1", 3)
192
+ expect(store.recent("s", "x:", 10)).to eq([["x:1", 1]])
193
+ end
194
+ end
195
+
131
196
  describe "#scopes" do
132
197
  it " returns scope names sorted lexicographically" do #
133
198
  store.set("zeta", "k", 1)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # A tool call the model wrote as TEXT instead of through the API's `tool_calls`.
7
+ #
8
+ # deepseek-v4-flash does it now and then: the message comes back with no tool call
9
+ # and a body like
10
+ #
11
+ # <tool_call>
12
+ # search_faq
13
+ # <parameter name="query">quem é a loja Exemplo</parameter>
14
+ # </tool_call>
15
+ #
16
+ # Left alone that is the turn's ANSWER: the tool never runs, the consumer's leak
17
+ # guard swallows the text and the customer gets silence — and the markup stays in
18
+ # the session history, where the model reads itself calling tools that way and does
19
+ # it again (Loja Exemplo, 23/10/2026). The Executor rewrites such a message
20
+ # into a real tool call before the next step, so the loop runs the tool and the
21
+ # history holds what a normal call would have left.
22
+ #
23
+ # Two spellings: the name-then-<parameter> one above (the one seen in production)
24
+ # and the Hermes/Qwen JSON one (`<tool_call>{"name": …, "arguments": {…}}`).
25
+ # Anything else — or a name the chat does not offer — is NOT recovered: inventing a
26
+ # call is worse than the silence the consumer already guards.
27
+ module TextToolCall
28
+ BLOCK = %r{<tool_call>\s*(.*?)\s*</tool_call>}m
29
+ PARAM = %r{<parameter\s+name\s*=\s*"([^"]+)"\s*>(.*?)</parameter>}m
30
+ NAME = /\A([A-Za-z_][\w.-]*)\s*(?:<|\z)/
31
+
32
+ module_function
33
+
34
+ # content: the assistant message text. known: the tool names the chat offers.
35
+ # -> { calls: [{ name:, arguments: }], text: what is left outside the blocks } | nil
36
+ def parse(content, known)
37
+ text = content.to_s
38
+ return nil unless text.include?("<tool_call>")
39
+
40
+ calls = text.scan(BLOCK).map { |(body)| call_from(body) }
41
+ return nil if calls.empty? || calls.any? { |call| call.nil? || !known.include?(call[:name]) }
42
+
43
+ { calls: calls, text: text.gsub(BLOCK, "").strip }
44
+ end
45
+
46
+ def call_from(body)
47
+ return json_call(body) if body.start_with?("{")
48
+
49
+ name = body[NAME, 1] or return nil
50
+ # ponytail: parameter values stay strings (the spelling carries no types);
51
+ # every tool seen leaking takes strings. Coerce by schema if one ever doesn't.
52
+ { name: name, arguments: body.scan(PARAM).to_h { |key, value| [key, value.strip] } }
53
+ end
54
+
55
+ def json_call(body)
56
+ data = JSON.parse(body)
57
+ return nil unless data.is_a?(Hash) && data["name"].is_a?(String)
58
+
59
+ arguments = data["arguments"]
60
+ arguments = JSON.parse(arguments) if arguments.is_a?(String)
61
+ { name: data["name"], arguments: arguments.is_a?(Hash) ? arguments : {} }
62
+ rescue JSON::ParserError
63
+ nil
64
+ end
65
+ end
66
+ end
data/lib/insika/tick.rb CHANGED
@@ -34,7 +34,7 @@ module Insika
34
34
  def initialize(store:, recovery:, channel_delivery:, logger: nil,
35
35
  interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
36
36
  sleeper: nil, retention: nil, funnel: nil, followup: nil,
37
- schedule: nil)
37
+ schedule: nil, model_registry: nil)
38
38
  @store = store
39
39
  @recovery = recovery
40
40
  @channel_delivery = channel_delivery
@@ -46,6 +46,7 @@ module Insika
46
46
  @funnel = funnel # the tick-driven outcome fold; nil = none
47
47
  @followup = followup # the tick-driven follow-up firer; nil = none
48
48
  @schedule = schedule # the recurring-schedule firer; nil = none
49
+ @model_registry = model_registry # the daily model-catalog refresh; nil = none
49
50
  end
50
51
 
51
52
  # the fold is wired after the Tick is built (the graph passes
@@ -61,6 +62,9 @@ module Insika
61
62
  # (same shape — the stores come from the spine).
62
63
  attr_accessor :schedule
63
64
 
65
+ # the daily RubyLLM model-catalog refresh (ModelRegistry), same shape.
66
+ attr_accessor :model_registry
67
+
64
68
  def enabled? = @interval.positive?
65
69
 
66
70
  # One pass, pure (no reactor needed): the serving loop calls it on a timer,
@@ -85,6 +89,8 @@ module Insika
85
89
  # the same claim-window discipline as the follow-up firer.
86
90
  schedule_summary = @schedule&.run
87
91
  summary[:schedule] = schedule_summary if schedule_summary
92
+ registry_summary = @model_registry&.run
93
+ summary[:model_registry] = registry_summary if registry_summary
88
94
  return summary unless claim_window
89
95
 
90
96
  result = @recovery.run(stale_after: @stale_after)
@@ -112,6 +112,10 @@ module Insika
112
112
  # NOTHING valid. -> { revoked: n, issue: Issue }.
113
113
  def rotate(tenant_id:, label: "default", now: Time.now)
114
114
  @store.transaction do
115
+ # A key no one writes, read first: on a backend that locks per key read,
116
+ # two rotations of one tenant run one after the other, so the second
117
+ # revokes the token the first issued.
118
+ @store.get(SCOPE, "#{ROTATE_PREFIX}#{tenant_id}")
115
119
  revoked = revoke_all(tenant_id: tenant_id)
116
120
  { revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
117
121
  end
@@ -145,6 +149,7 @@ module Insika
145
149
  end
146
150
 
147
151
  RECORD_PREFIX = "r:"
152
+ ROTATE_PREFIX = "rotate:" # never written: only read, to serialize rotations
148
153
  HASH_PREFIX = "h:"
149
154
 
150
155
  def record_key(id) = "#{RECORD_PREFIX}#{id}"
@@ -39,7 +39,7 @@ module Insika
39
39
  # From after_tool_result, with the RAW result: a Tool::Halt is only
40
40
  # recognizable there.
41
41
  def halt!(result)
42
- @halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
42
+ @halted = true if defined?(Insika::ToolDefinition::Halt) && result.is_a?(Insika::ToolDefinition::Halt)
43
43
  end
44
44
 
45
45
  def halted? = @halted
@@ -14,13 +14,20 @@ module Insika
14
14
  # no instantiation at all; a broken factory surfaces on first use (where the
15
15
  # Executor would also catch it at stage 3), not at construction.
16
16
  class ToolCatalog
17
- Entry = Data.define(:name, :description)
17
+ Entry = Data.define(:name, :description, :plugin) do
18
+ def initialize(name:, description:, plugin: nil)
19
+ super
20
+ end
21
+ end
18
22
 
19
23
  def initialize(tool_registry:)
20
24
  @tool_registry = tool_registry
21
25
  end
22
26
 
23
27
  def all
28
+ gen = registry_generation
29
+ @entries = nil if gen != @generation
30
+ @generation = gen
24
31
  @entries ||= build_entries
25
32
  end
26
33
 
@@ -29,6 +36,7 @@ module Insika
29
36
  # restart. An in-flight turn has already captured `all`.
30
37
  def reload
31
38
  @entries = build_entries
39
+ @generation = registry_generation
32
40
  self
33
41
  end
34
42
 
@@ -76,7 +84,7 @@ module Insika
76
84
 
77
85
  def build_entries
78
86
  @tool_registry.entries.map do |entry|
79
- Entry.new(name: entry.name, description: entry.factory.call.description.to_s)
87
+ Entry.new(name: entry.name, description: entry.factory.call.description.to_s, plugin: entry.plugin)
80
88
  end
81
89
  end
82
90
 
@@ -85,5 +93,7 @@ module Insika
85
93
  desc = entry.description.downcase
86
94
  terms.sum { |term| (name.include?(term) ? 2 : 0) + (desc.include?(term) ? 1 : 0) }
87
95
  end
96
+
97
+ def registry_generation = @tool_registry.respond_to?(:generation) ? @tool_registry.generation : nil
88
98
  end
89
99
  end
@@ -20,7 +20,7 @@ module Insika
20
20
  # "group" => string|nil, "tags" => ["b2b",...] } #//
21
21
  #
22
22
  # `parameters` is **JSON Schema** (the interlingua of OpenAI/Anthropic/MCP):
23
- # a nestable object, fed straight into RubyLLM's `params_schema` (provider-
23
+ # a nestable object, fed straight into RubyLLM's `parameters_schema` (provider-
24
24
  # agnostic). The **flat array** (`[{name,type,required}]`) is SUGAR for the simple
25
25
  # case: it is lifted to JSON Schema at build time. The sugar covers scalars and
26
26
  # `array:<scalar>` — it CANNOT express an array of objects, and says so instead of
@@ -41,6 +41,11 @@ module Insika
41
41
  )
42
42
 
43
43
  class ToolDefinition
44
+ # Terminal tool results finish the current batch without another model request.
45
+ Halt = Data.define(:content) do
46
+ def to_s = content.to_s
47
+ end
48
+
44
49
  # Flat-sugar types: the SCALARS, plus `array:<scalar>` for a list. There is no bare
45
50
  # `array`: an array without an item type is an INCOMPLETE declaration, and the
46
51
  # engine refuses to guess one (see lift_flat_params).
@@ -163,6 +168,8 @@ module Insika
163
168
 
164
169
  def presentation? = !presentation.nil?
165
170
 
171
+ # Public because an MCP tool has the same question to answer and there must be
172
+ # one reading of "these parameters may only carry an id some tool returned".
166
173
  def self.normalize_requires_evidence(value, param_names)
167
174
  return nil if value.nil?
168
175
 
@@ -173,7 +180,6 @@ module Insika
173
180
 
174
181
  { "params" => params.map(&:to_s).uniq }
175
182
  end
176
- private_class_method :normalize_requires_evidence
177
183
 
178
184
  # Group: enablement label by DATA (not name convention),
179
185
  # target of AgentProfile's `tools_allow_groups`. Trimmed; empty/nil -> nil.
@@ -542,7 +548,7 @@ module Insika
542
548
  nil
543
549
  end
544
550
 
545
- # HOW `say` REACHES THE EXECUTOR. RubyLLM's `Tool::Halt` carries one value, and
551
+ # HOW `say` REACHES THE EXECUTOR. Insika::ToolDefinition::Halt carries one value, and
546
552
  # that value is the tool's payload (the trace records it, and the model never
547
553
  # sees it — the halt ends the loop). So a halt that has something to publish
548
554
  # carries BOTH, under keys distinctive enough that a trace reader knows what
@@ -576,7 +582,7 @@ module Insika
576
582
 
577
583
  # FLAT view of the top-level properties (name/type/description/required) — for
578
584
  # RubyLLM's `#parameters` (discovery/tool_search) and the simple authoring UI.
579
- # The full nested schema goes through `params_schema` (DataDefinedTool). Symbol-
585
+ # The full nested schema goes through `parameters_schema` (DataDefinedTool). Symbol-
580
586
  # keyed for compat with callers that already consumed the flat params.
581
587
  def top_level_params
582
588
  props = parameters["properties"] || {}