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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Insika
6
+ module Telemetry
7
+ # One instance belongs to one chat context, never to a graph or current fiber.
8
+ class RubyLLMInstrumenter
9
+ def initialize(emit:, delegate: nil, operation: nil, model: nil, pricing: nil)
10
+ @emit, @delegate, @operation, @model, @pricing = emit, delegate, operation, model, pricing
11
+ end
12
+
13
+ def instrument(name, payload = {}, &block)
14
+ return chat(payload, &block) if name == "chat.ruby_llm" && block
15
+ return request(payload, &block) if name == "request.ruby_llm" && block
16
+
17
+ result = delegate(name, payload, &block)
18
+ record(:llm_usage, payload) if name == "usage.ruby_llm"
19
+ result
20
+ end
21
+
22
+ private
23
+
24
+ def chat(payload, &block)
25
+ previous_model, previous_request = @model, @request_id
26
+ @model, @request_id = scalar(payload[:model]), nil
27
+ delegate("chat.ruby_llm", payload, &block)
28
+ ensure
29
+ @model, @request_id = previous_model, previous_request
30
+ end
31
+
32
+ def request(payload, &block)
33
+ @request_id = SecureRandom.uuid
34
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ error = nil
36
+ begin
37
+ delegate("request.ruby_llm", payload, &block)
38
+ rescue Exception => error # Preserve cancellation and the original model exception too.
39
+ raise
40
+ ensure
41
+ record(:llm_request, payload, error: error,
42
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
43
+ end
44
+ end
45
+
46
+ # Host notifications still wrap the operation. A broken subscriber must not
47
+ # retry the model, replace its result, or swallow/replace its exception.
48
+ def delegate(name, payload, &block)
49
+ return block&.call(payload) unless @delegate.respond_to?(:instrument)
50
+
51
+ called = false
52
+ result = error = nil
53
+ operation = proc do |*args|
54
+ called = true
55
+ begin
56
+ result = block.call(*args)
57
+ rescue Exception => caught
58
+ error = caught
59
+ raise
60
+ end
61
+ end
62
+ begin
63
+ block ? @delegate.instrument(name, payload, &operation) : @delegate.instrument(name, payload)
64
+ rescue StandardError
65
+ # Telemetry is best effort; the operation's outcome is authoritative.
66
+ end
67
+ raise error if error
68
+ return unless block
69
+
70
+ called ? result : block.call(payload)
71
+ end
72
+
73
+ def record(type, payload, error: nil, duration_ms: nil)
74
+ data = {
75
+ "operation" => scalar(payload[:operation] || @operation),
76
+ "provider" => scalar(payload[:provider]),
77
+ "model" => scalar(payload[:model] || @model),
78
+ "status" => type == :llm_request ? (error ? "failed" : "succeeded") : scalar(payload[:status]),
79
+ "request_id" => @request_id
80
+ }
81
+ if type == :llm_request
82
+ data["duration_ms"] = duration_ms
83
+ data["exception_class"] = error.class.name if error
84
+ else
85
+ %i[input output cache_read cache_write thinking].each do |field|
86
+ value = payload[:tokens]&.public_send(field)
87
+ data["#{field}_tokens"] = value.is_a?(Numeric) ? value : nil
88
+ end
89
+ value = payload[:cost]&.total
90
+ value = priced(data) unless value.is_a?(Numeric)
91
+ data["cost"] = value.is_a?(Numeric) ? value : nil
92
+ end
93
+ @emit.call(type, data)
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ # The operator's rates fill in only what the library could not price (Pricing.from_env).
99
+ def priced(data)
100
+ @pricing&.cost(model: data["model"], input_tokens: data["input_tokens"],
101
+ output_tokens: data["output_tokens"], cached_tokens: data["cache_read_tokens"],
102
+ cache_creation_tokens: data["cache_write_tokens"])
103
+ end
104
+
105
+ def scalar(value)
106
+ value.to_s if value.is_a?(String) || value.is_a?(Symbol)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -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}"
@@ -58,14 +58,13 @@ module Insika
58
58
  # capability alias.
59
59
  def assemble_tool_instances(allowed, state)
60
60
  names = state.respond_to?(:capability_names) ? (state.capability_names || {}) : {}
61
- ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
62
- return instantiate_tools(allowed, ctx) if names.empty?
61
+ return instantiate_tools(allowed, state) if names.empty?
63
62
 
64
63
  # Dedup by the ENTRY NAME (registry key = impl_name) BEFORE
65
64
  # instantiating — the INSTANCE's `.name` (RubyLLM) is not the registration
66
65
  # name.
67
66
  direct = Array(allowed).reject { |e| e.respond_to?(:name) && names.key?(e.name.to_s) }
68
- instantiate_tools(direct, ctx) + capability_tool_instances(names, ctx)
67
+ instantiate_tools(direct, state) + capability_tool_instances(names, state)
69
68
  end
70
69
 
71
70
  # Envelopes each allowed tool (per-call timeout + side-effect recording).
@@ -78,7 +77,7 @@ module Insika
78
77
  ToolEnvelope.new(tool, state: state, checkpoint_store: @checkpoint_store,
79
78
  tool_registry: @tool_registry, timeout: timeout,
80
79
  skip_side_effects: skip_side_effects,
81
- trace_recorder: @tool_trace_store)
80
+ trace_recorder: @tool_trace_store, event_stream: @event_stream)
82
81
  end
83
82
  end
84
83
 
@@ -99,16 +98,19 @@ module Insika
99
98
  return unless state.respond_to?(:tool_gate) && state.respond_to?(:tool_concurrency)
100
99
 
101
100
  cap = state.tool_concurrency
102
- state.tool_gate = cap ? Async::Semaphore.new(cap) : nil
101
+ state.tool_gate = cap && cap > 1 ? Async::Semaphore.new(cap) : nil
102
+ if state.respond_to?(:side_effect_gate=)
103
+ state.side_effect_gate = state.tool_gate ? Async::Semaphore.new(1) : nil
104
+ end
103
105
  end
104
106
 
105
107
  # Real Engine -> Entries (respond to factory); fakes -> ready instances.
106
108
  # `turn_context` is deposited into the instances that expose it
107
109
  # (data-tools); the rest ignore it (parity).
108
- def instantiate_tools(allowed, turn_context = nil)
110
+ def instantiate_tools(allowed, state = nil)
109
111
  Array(allowed).map do |t|
110
112
  tool = t.respond_to?(:factory) ? t.factory.call : t
111
- inject_turn_context(tool, turn_context)
113
+ inject_turn_context(tool, state)
112
114
  tool
113
115
  end
114
116
  end
@@ -116,22 +118,28 @@ module Insika
116
118
  # seam: deposits the turn context into the freshly created instance
117
119
  # (same idea as `remember`, which receives tenant/state) BEFORE the
118
120
  # ToolEnvelope. Duck-typed: only what exposes `turn_context=` (DataDefinedTool)
119
- # receives it. nil (a state with no turn_context, e.g. a test stub) -> no-op.
120
- def inject_turn_context(tool, turn_context)
121
- return if turn_context.nil?
121
+ # receives it; a tool that exposes `turn_state=` (a presentation tool, which
122
+ # reads the ledger and the hoarded cards) receives the state itself. nil (a
123
+ # state with no turn_context, e.g. a test stub) -> no-op.
124
+ def inject_turn_context(tool, state)
125
+ return if state.nil?
126
+
127
+ tool.turn_state = state if tool.respond_to?(:turn_state=)
128
+ ctx = state.respond_to?(:turn_context) ? state.turn_context : nil
129
+ return if ctx.nil?
122
130
 
123
- tool.turn_context = turn_context if tool.respond_to?(:turn_context=)
131
+ tool.turn_context = ctx if tool.respond_to?(:turn_context=)
124
132
  end
125
133
 
126
134
  # impl_name -> Capability::ResolvedTool(capability_name:), STILL without
127
135
  # ToolEnvelope (the call site's wrap_tools wraps the whole set — same
128
136
  # order impl -> ResolvedTool -> ToolEnvelope). entry already validated in
129
137
  # resolve_capabilities.
130
- def capability_tool_instances(names, turn_context = nil)
138
+ def capability_tool_instances(names, state = nil)
131
139
  names.map do |impl_name, capability_name|
132
140
  entry = @tool_registry.entries.find { |e| e.name == impl_name }
133
141
  tool = entry.factory.call
134
- inject_turn_context(tool, turn_context)
142
+ inject_turn_context(tool, state)
135
143
  Capability::ResolvedTool.new(tool, capability_name: capability_name,
136
144
  impl_name: impl_name)
137
145
  end
@@ -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