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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Third-party text — a product description, a review, an FAQ body, a fact a
5
+ # customer dictated — reaches the model as bytes, and the model reads every
6
+ # one of them. Those bytes can carry zero-width characters, bidi overrides, a
7
+ # forged "\n\nassistant:" turn marker or a tag shaped like the engine's own
8
+ # transcript markup. This is the ONE sanitizer for that class of input:
9
+ # pure Ruby, stdlib only, no IO, every pattern bounded so a hostile megabyte
10
+ # finishes in linear time on the reactor.
11
+ #
12
+ # It never re-wraps. The engine already renders data inside fixed labels
13
+ # (<memory>, <knowledge>, a tool result) and the FenceNotice provider names
14
+ # them to the model; what this module does is make sure the DATA cannot
15
+ # reproduce those labels or a turn boundary.
16
+ module Fence
17
+ module_function
18
+
19
+ # Zero-width, bidi and format controls: the usual carriers of hidden text.
20
+ INVISIBLE = /[\u00AD\u061C\u180E\u200B-\u200F\u2028-\u202E\u2060-\u2064\u2066-\u2069\u206A-\u206F\uFE00-\uFE0F\uFEFF\uFFF9-\uFFFB\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/
21
+
22
+ # C0/C1 controls except tab, newline and carriage return.
23
+ CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/
24
+
25
+ # Tag names that read as transcript or tool-call markup to a model, plus the
26
+ # labels the engine itself renders around data. A copy of any of them inside
27
+ # DATA is a forgery — `<fact>` inside a fact value would end the fact early.
28
+ TAG_NAMES = %w[
29
+ system human user assistant transcript conversation
30
+ tool_result tool_use function_calls function_results invoke
31
+ memory knowledge fact note concept briefing conversation_summary request_context
32
+ ].freeze
33
+
34
+ # Only tag-SHAPED text: bare (`<system>`), closing (`</memory>`), self-closing,
35
+ # optionally namespaced (`<ns:invoke>`), with at most eight `name="value"`
36
+ # attributes. Bare words are not attributes, so `<system requirements>`
37
+ # passes. Quantifiers are bounded and non-adjacent — linear on unclosed input.
38
+ TAG_ATTRS = /(?:[ \t]+[\w:.-]{1,40}[ \t]*=[ \t]*(?:"[^"]{0,200}"|'[^']{0,200}'|[^\s"'>]{1,200})){0,8}/
39
+ TAG = /<[ \t]*\/?[ \t]*(?:[a-z][\w.-]{0,30}:)?(?:#{TAG_NAMES.join('|')})\b#{TAG_ATTRS.source}[ \t]*\/?>/i
40
+
41
+ # Provider special tokens (`<|im_start|>`, `<|endoftext|>`).
42
+ SPECIAL_TOKEN = /<\|[^|<>\r\n]{1,64}\|>/
43
+
44
+ # A forged turn boundary: a blank line (or the very start), a full role word,
45
+ # a colon. A mid-sentence "user:" and a one-letter list marker ("A:") do not
46
+ # match. The colon becomes " -", so the words survive as prose.
47
+ TURN_MARKER = /(\A[ \t]*|(?:\r\n|\r|\n)[ \t]*(?:\r\n|\r|\n)[ \t]*)(human|assistant|system|user)[ \t]*:/i
48
+
49
+ REMOVED = "[removed]"
50
+ TRUNCATED = " …[truncated]"
51
+ # One string leaf after the envelope; `Settings fencing.max_chars` overrides.
52
+ DEFAULT_MAX_CHARS = 12_000
53
+
54
+ # -> String. `max_chars` bounds the result INCLUDING the suffix.
55
+ def sanitize_text(text, max_chars: nil)
56
+ s = Coercion.utf8(text).unicode_normalize(:nfkc)
57
+ s = s.gsub(INVISIBLE, "").gsub(CONTROL, " ")
58
+ # To a fixpoint: a tag nested inside another (`</memory</memory>>`) must
59
+ # not reassemble once the inner one goes. Each round shrinks the string or
60
+ # stops, so the loop is bounded by the number of tags.
61
+ loop do
62
+ stripped = s.gsub(TAG, REMOVED).gsub(SPECIAL_TOKEN, REMOVED)
63
+ break if stripped == s
64
+
65
+ s = stripped
66
+ end
67
+ s = s.gsub(TURN_MARKER) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)} -" }
68
+ cap(s, max_chars)
69
+ end
70
+
71
+ # Walks Hash/Array leaves; String leaves are sanitized, keys and every other
72
+ # type pass through untouched (numbers, booleans, nil are not text).
73
+ def sanitize_value(obj, max_chars: nil)
74
+ case obj
75
+ when String then sanitize_text(obj, max_chars: max_chars)
76
+ when Hash then obj.transform_values { |v| sanitize_value(v, max_chars: max_chars) }
77
+ when Array then obj.map { |v| sanitize_value(v, max_chars: max_chars) }
78
+ else obj
79
+ end
80
+ end
81
+
82
+ # The per-agent switch, read the same way everywhere (envelope, providers,
83
+ # doctor): an operator's `fencing true`, a form's "1" or a JSON `true`.
84
+ def enabled?(profile)
85
+ profile.respond_to?(:fencing) && Coercion.truthy?(profile.fencing)
86
+ end
87
+
88
+ def cap(text, max_chars)
89
+ return text if max_chars.nil? || text.length <= max_chars
90
+ return text[0, max_chars] if max_chars <= TRUNCATED.length
91
+
92
+ text[0, max_chars - TRUNCATED.length] + TRUNCATED
93
+ end
94
+ private_class_method :cap
95
+ end
96
+ end
@@ -148,6 +148,9 @@ module Insika
148
148
  # when present. A case that lost its reference in a round-trip would stop being
149
149
  # compared against the incumbent and the report would look identical.
150
150
  h["reference"] = golden.reference unless golden.reference.empty?
151
+ # And `state`: a snapshot case that lost its snapshot would replay from an
152
+ # empty conversation and fail for a reason that has nothing to do with the agent.
153
+ h["state"] = golden.state unless golden.state.empty?
151
154
  # `tenant` follows the same omit-when-default rule: "platform" is the
152
155
  # loader's own default, so leaving it out reproduces the same case; an
153
156
  # explicit tenant is a case's isolation boundary and must never round-trip away.
@@ -89,7 +89,7 @@ module Insika
89
89
  # first-10 audit reads back.
90
90
 
91
91
  # ask: ->(prompt) { "<raw model text>" } | something answering #content
92
- # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
92
+ # (+ RubyLLM-compatible #tokens for cost).
93
93
  # model: the ref recorded as the candidate's `proposer` ("utility_model"
94
94
  # default).
95
95
  attr_reader :model
@@ -110,7 +110,7 @@ module Insika
110
110
  # max_proposals: cap on surviving raw skills. Drops counted, never fixed.
111
111
  def mine(prompt:, message_counts:, max_proposals: 10)
112
112
  answer = @ask.call(prompt)
113
- raw = parse(text_of(answer))
113
+ raw = parse(answer)
114
114
  skills = []
115
115
  dropped = DROP_KEYS.to_h { |k| [k, 0] }
116
116
  seen = {}
@@ -142,20 +142,25 @@ module Insika
142
142
  # discipline). The cached prefix is INCLUDED in the spent total (E1: the
143
143
  # run's cost is the harvest's only side spend).
144
144
  def cost_of(answer)
145
- return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
145
+ return nil unless answer.respond_to?(:tokens)
146
146
 
147
- input = answer.input_tokens.to_i
148
- output = answer.output_tokens.to_i
149
- cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
150
- spent = input + output + cached
147
+ usage = answer.tokens
148
+ input = usage.input.to_i
149
+ output = usage.output.to_i
150
+ cached = usage.cache_read.to_i
151
+ spent = input + output + cached + usage.cache_write.to_i
151
152
  spent.positive? ? { "spent" => spent, "cached" => cached } : nil
152
153
  end
153
154
 
154
- # Fences stripped, parsed STRICTLY (the Proposer's discipline): a model
155
- # that improvises a schema fails here instead of producing half a skill.
156
- def parse(raw)
157
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
158
- parsed = JSON.parse(body)
155
+ # Native responses own JSON parsing; injected text asks retain their
156
+ # existing fenced-JSON contract. Domain validation still runs below.
157
+ def parse(answer)
158
+ parsed = if answer.respond_to?(:parsed)
159
+ answer.parsed
160
+ else
161
+ JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
162
+ end
163
+ parsed = parsed["items"] if parsed.is_a?(Hash)
159
164
  raise Unusable, "the miner's answer is not an array" unless parsed.is_a?(Array)
160
165
 
161
166
  parsed
@@ -214,6 +219,10 @@ module Insika
214
219
  return nil if ref.nil?
215
220
 
216
221
  provider, model = split_ref(ref)
222
+ # A bare "model" ref falls back to the agent's own provider (stamped
223
+ # by RunHarvest) — RubyLLM refuses a nil provider with
224
+ # assume_model_exists, so a bare ref used to fail every run.
225
+ provider ||= Coercion.presence(config && config["provider"])
217
226
  factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
218
227
  Miner.new(ask: factory.call(model, provider), model: ref)
219
228
  end
@@ -232,10 +241,13 @@ module Insika
232
241
  require "ruby_llm"
233
242
  llm ||= RubyLLM
234
243
  lambda do |prompt|
244
+ schema = { "type" => "object", "properties" => { "items" => SKILL_SCHEMA.json_schema },
245
+ "required" => ["items"], "additionalProperties" => false }
235
246
  llm.chat(model: model, provider: provider, assume_model_exists: true)
236
- .with_temperature(0).ask(prompt)
247
+ .with_temperature(0).with_schema("schema" => schema, "strict" => false)
248
+ .ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
237
249
  end
238
250
  end
239
251
  end
240
252
  end
241
- end
253
+ end
@@ -125,6 +125,7 @@ module Insika
125
125
  def due_sessions
126
126
  due = []
127
127
  @session_store.each_id do |id|
128
+ next if Insika::SessionStore.synthetic?(id) # not customers — and skips reading the record
128
129
  session = @session_store.find(id)
129
130
  next unless session
130
131
  next if @harvest_store.mined?(id)
@@ -196,6 +196,18 @@ module Insika
196
196
 
197
197
  # gated -> awaiting_approval. The refusal parks: a conversion REFUSAL
198
198
  # keeps the candidate AT gated (the page shows the ruler's hole).
199
+ # A parked candidate (gated on a missing ruler: no judge, no criterion,
200
+ # no origin) goes back to pending so the operator can re-run the gate
201
+ # once the hole is fixed. The last reports stay until the new gate
202
+ # overwrites them. gated -> pending only.
203
+ def reopen(candidate_id)
204
+ update_candidate(candidate_id) do |record|
205
+ guard_candidate_state!(record, "gated")
206
+ record["status"] = "pending"
207
+ record["updated_at"] = timestamp
208
+ end
209
+ end
210
+
199
211
  def mark_awaiting(candidate_id)
200
212
  update_candidate(candidate_id) do |record|
201
213
  guard_candidate_state!(record, "gated")
@@ -24,8 +24,21 @@ module Insika
24
24
 
25
25
  class ResponseTooLarge < Insika::Error; end
26
26
 
27
- def initialize(max_bytes: MAX_BYTES)
27
+ DNS_TTL = 60 # seconds
28
+ # A connection idle longer than this is reopened instead of reused. Kept well
29
+ # under common server idle timeouts: Net::HTTP retries only idempotent methods,
30
+ # so a POST on a socket the server already closed would fail the tool call.
31
+ KEEP_ALIVE = 5 # seconds
32
+ MAX_IDLE_PER_HOST = 16
33
+
34
+ def initialize(max_bytes: MAX_BYTES, dns_ttl: DNS_TTL, keep_alive: KEEP_ALIVE)
28
35
  @max_bytes = max_bytes
36
+ @dns_ttl = dns_ttl
37
+ @keep_alive = keep_alive
38
+ @dns = {}
39
+ @idle = Hash.new { |h, k| h[k] = [] }
40
+ @pid = Process.pid
41
+ @lock = Mutex.new
29
42
  end
30
43
 
31
44
  def request(method:, url:, headers: {}, body: nil, timeout: nil)
@@ -34,9 +47,7 @@ module Insika
34
47
  headers.each { |k, v| req[k] = v }
35
48
  req.body = body if body && !body.to_s.empty?
36
49
 
37
- t = timeout || DEFAULT_TIMEOUT
38
- opts = { use_ssl: uri.scheme == "https", open_timeout: t, read_timeout: t }
39
- Net::HTTP.start(uri.host, uri.port, opts) do |http|
50
+ with_connection(uri, timeout || DEFAULT_TIMEOUT) do |http|
40
51
  result = nil
41
52
  http.request(req) do |resp|
42
53
  # Accumulate in BINARY: Net::HTTP yields ASCII-8BIT chunks and a
@@ -55,9 +66,81 @@ module Insika
55
66
  # The redirect TARGET, so a moved API is reported as "moved to <url>"
56
67
  # instead of a bare 3xx with the empty body servers send with it.
57
68
  result[:location] = resp["location"].to_s if resp["location"]
69
+ # The server's own processing time (Rack::Runtime's X-Runtime, seconds),
70
+ # so a slow call can be split into server work and everything else.
71
+ runtime = Float(resp["x-runtime"], exception: false)
72
+ result[:server_ms] = (runtime * 1000).round if runtime
58
73
  end
59
74
  result
60
75
  end
61
76
  end
77
+
78
+ private
79
+
80
+ # Reuses an idle keep-alive connection to the same origin (a new TLS connection
81
+ # costs several round trips). One caller holds a connection at a time; one that
82
+ # raised is closed, never returned, since its state is unknown.
83
+ def with_connection(uri, timeout)
84
+ key = [uri.scheme, uri.host, uri.port]
85
+ http = checkout(key) || connect(uri, timeout)
86
+ http.read_timeout = timeout
87
+ result = yield http
88
+ checkin(key, http)
89
+ result
90
+ rescue StandardError
91
+ http&.finish if http&.started?
92
+ raise
93
+ end
94
+
95
+ def connect(uri, timeout)
96
+ # `ipaddr` connects to the cached address; TLS (SNI, certificate check) and the
97
+ # Host header still use the hostname.
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.ipaddr = address(uri.host)
100
+ http.use_ssl = uri.scheme == "https"
101
+ http.open_timeout = timeout
102
+ http.keep_alive_timeout = @keep_alive
103
+ http.start
104
+ end
105
+
106
+ def checkout(key)
107
+ @lock.synchronize do
108
+ reset_after_fork
109
+ @idle[key].pop
110
+ end
111
+ end
112
+
113
+ def checkin(key, http)
114
+ kept = @lock.synchronize do
115
+ next false if @idle[key].size >= MAX_IDLE_PER_HOST
116
+
117
+ @idle[key].push(http)
118
+ true
119
+ end
120
+ http.finish unless kept
121
+ end
122
+
123
+ # A forked child must not share its parent's sockets.
124
+ def reset_after_fork
125
+ return if @pid == Process.pid
126
+
127
+ @pid = Process.pid
128
+ @idle.clear
129
+ end
130
+
131
+ # Net::HTTP resolves with getaddrinfo, which blocks the whole reactor under
132
+ # Falcon: a burst of N tool calls queued N lookups and froze every turn of the
133
+ # process meanwhile. Resolving once per host per TTL makes that rare.
134
+ # ponytail: the lookup on a miss still blocks; a fiber-aware resolver removes it
135
+ # if one host per minute ever shows up in a profile.
136
+ def address(host)
137
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
+ hit = @lock.synchronize { @dns[host] }
139
+ return hit[0] if hit && hit[1] > now
140
+
141
+ ip = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).first.ip_address
142
+ @lock.synchronize { @dns[host] = [ip, now + @dns_ttl] }
143
+ ip
144
+ end
62
145
  end
63
146
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Kits: named bundles of skills, tools and tool groups that agents subscribe to
5
+ # (AgentProfile#kits). Stored in Settings["kits"]; applied at READ time by
6
+ # AgentDefaults::ProfileSource, so editing a kit reaches every subscriber on its
7
+ # next turn and the agent's own record never holds a kit's items.
8
+ #
9
+ # A kit only ADDS to an explicit allowlist. An agent whose allowlist is "all"
10
+ # (nil) already sees everything, so the kit leaves it alone.
11
+ module Kits
12
+ NAME = /\A[a-z0-9][a-z0-9_-]{0,63}\z/
13
+ LISTS = %w[skills tools tool_groups].freeze
14
+
15
+ module_function
16
+
17
+ # -> the normalized kit { "description", "skills", "tools", "tool_groups" }.
18
+ def validate!(name, kit)
19
+ raise Insika::ValidationError, "kit name must be a lowercase slug" unless name.to_s.match?(NAME)
20
+ raise Insika::ValidationError, "kit must be an object" unless kit.is_a?(Hash)
21
+
22
+ kit = kit.transform_keys(&:to_s)
23
+ out = { "description" => kit["description"].to_s.strip }
24
+ LISTS.each do |key|
25
+ value = kit[key]
26
+ raise Insika::ValidationError, "kit #{key} must be a list" unless value.nil? || value.is_a?(Array)
27
+
28
+ out[key] = Array(value).map { |v| v.to_s.strip }.reject(&:empty?).uniq
29
+ end
30
+ out
31
+ end
32
+
33
+ # -> the profile with its kits' items unioned in (the same object when there is
34
+ # nothing to add). Unknown kit names are ignored: a deleted kit just stops applying.
35
+ def apply(profile, kits_by_name)
36
+ # A malformed stored kit is skipped, never raised: one bad record must not stop
37
+ # every turn of every subscriber (the same rule as AgentDefaults).
38
+ chosen = Array(profile.kits).filter_map { |name| kits_by_name[name] }.grep(Hash)
39
+ return profile if chosen.empty?
40
+
41
+ pick = ->(key) { chosen.flat_map { |k| Array(k[key]) }.uniq }
42
+ changes = {}
43
+ unless profile.tools_allow.nil? && profile.tools_allow_groups.nil?
44
+ tools = pick.("tools")
45
+ groups = pick.("tool_groups")
46
+ changes[:tools_allow] = Array(profile.tools_allow) | tools if tools.any?
47
+ changes[:tools_allow_groups] = Array(profile.tools_allow_groups) | groups if groups.any?
48
+ end
49
+ skills = pick.("skills")
50
+ changes[:skills] = Array(profile.skills) | skills if !profile.skills.nil? && skills.any?
51
+ changes.empty? ? profile : profile.with(**changes)
52
+ end
53
+ end
54
+ end
@@ -244,7 +244,7 @@ module Insika
244
244
  attr_reader :model, :types
245
245
 
246
246
  # ask: ->(prompt) { "<raw model text>" } | something answering #content
247
- # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
247
+ # (+ RubyLLM-compatible #tokens for cost).
248
248
  # model: the ref recorded on the concept's provenance ("utility_model"
249
249
  # default).
250
250
  # types: the allowed concept types (the profile's `knowledge.types`,
@@ -262,7 +262,7 @@ module Insika
262
262
  # cost: { "spent" => N, "cached" => N } | nil }
263
263
  def extract(prompt:, max_concepts: 10)
264
264
  answer = @ask.call(prompt)
265
- raw = parse(text_of(answer))
265
+ raw = parse(answer)
266
266
  concepts = []
267
267
  dropped = DROP_KEYS.to_h { |k| [k, 0] }
268
268
  seen = {}
@@ -291,21 +291,25 @@ module Insika
291
291
  def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
292
292
 
293
293
  def cost_of(answer)
294
- return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
294
+ return nil unless answer.respond_to?(:tokens)
295
295
 
296
- input = answer.input_tokens.to_i
297
- output = answer.output_tokens.to_i
298
- cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
299
- spent = input + output + cached
296
+ usage = answer.tokens
297
+ input = usage.input.to_i
298
+ output = usage.output.to_i
299
+ cached = usage.cache_read.to_i
300
+ spent = input + output + cached + usage.cache_write.to_i
300
301
  spent.positive? ? { "spent" => spent, "cached" => cached } : nil
301
302
  end
302
303
 
303
- # Fences stripped, parsed STRICTLY: a model that improvises a schema
304
- # fails here instead of producing half a concept, never persisted
305
- # half-parsed.
306
- def parse(raw)
307
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
308
- parsed = JSON.parse(body)
304
+ # Native responses own JSON parsing; injected text asks retain their
305
+ # existing fenced-JSON contract. Domain validation still runs below.
306
+ def parse(answer)
307
+ parsed = if answer.respond_to?(:parsed)
308
+ answer.parsed
309
+ else
310
+ JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
311
+ end
312
+ parsed = parsed["items"] if parsed.is_a?(Hash)
309
313
  raise Unusable, "the extractor's answer is not an array" unless parsed.is_a?(Array)
310
314
 
311
315
  parsed
@@ -368,12 +372,17 @@ module Insika
368
372
  # Temperature 0: extraction should be deterministic for the same turn.
369
373
  # `ruby_llm` required lazily so nothing loads a provider gem until an
370
374
  # extractor is actually configured (load_guard stays green).
371
- def ruby_llm_ask(model, provider, llm: nil)
375
+ def ruby_llm_ask(model, provider, llm: nil, schema: nil)
372
376
  require "ruby_llm"
373
377
  llm ||= RubyLLM
374
378
  lambda do |prompt|
379
+ unless schema
380
+ prompt = "#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\"."
381
+ end
382
+ response_schema = schema || { "type" => "object", "properties" => { "items" => CONCEPT_SCHEMA.json_schema },
383
+ "required" => ["items"], "additionalProperties" => false }
375
384
  llm.chat(model: model, provider: provider, assume_model_exists: true)
376
- .with_temperature(0).ask(prompt)
385
+ .with_temperature(0).with_schema("schema" => response_schema, "strict" => false).ask(prompt)
377
386
  end
378
387
  end
379
388
  end
@@ -434,7 +443,7 @@ module Insika
434
443
  # -> { verdict: :related, merged_body: String } | { verdict: :contradicting }
435
444
  def resolve(existing_body:, new_body:)
436
445
  answer = @ask.call(prompt_for(existing_body, new_body))
437
- parsed = parse(text_of(answer))
446
+ parsed = parse(answer)
438
447
  raise Unusable, "not a JSON object" unless VERDICT_SCHEMA.call(parsed).success?
439
448
 
440
449
  classify(parsed)
@@ -455,9 +464,12 @@ module Insika
455
464
 
456
465
  def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
457
466
 
458
- def parse(raw)
459
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
460
- parsed = JSON.parse(body)
467
+ def parse(answer)
468
+ parsed = if answer.respond_to?(:parsed)
469
+ answer.parsed
470
+ else
471
+ JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
472
+ end
461
473
  raise Unusable, "the consolidator's answer is not an object" unless parsed.is_a?(Hash)
462
474
 
463
475
  parsed
@@ -492,7 +504,9 @@ module Insika
492
504
  return nil if ref.nil?
493
505
 
494
506
  provider, model = ExtractorFactory.split_ref(ref)
495
- factory = ask_factory || ->(m, p) { ExtractorFactory.ruby_llm_ask(m, p, llm: llm) }
507
+ factory = ask_factory || ->(m, p) {
508
+ ExtractorFactory.ruby_llm_ask(m, p, llm: llm, schema: Consolidator::VERDICT_SCHEMA.json_schema)
509
+ }
496
510
  Consolidator.new(ask: factory.call(model, provider), model: ref)
497
511
  end
498
512
  end
@@ -48,6 +48,7 @@ module Insika
48
48
  next
49
49
  end
50
50
 
51
+ remember_boot(config, api)
51
52
  if set_accessor(config, "#{api}_api_key", key)
52
53
  base = fetch(rec, :base_url)
53
54
  set_accessor(config, "#{api}_api_base", base) if base && !base.to_s.empty?
@@ -61,16 +62,43 @@ module Insika
61
62
  { applied: applied, skipped: skipped }
62
63
  end
63
64
 
64
- # UNDOES a provider's config in RubyLLM at runtime (delete without a restart,
65
- # clears `<api>_api_key`/`<api>_api_base`. A provider that RubyLLM
65
+ # Re-applies the stored providers when they changed since THIS process last
66
+ # applied them. The Studio's `apply` reaches only the worker that served the
67
+ # edit; every other worker (and every worker after a restart) gets the
68
+ # provider here. Called per chat build, so it mutates the config only when the
69
+ # store changed, which is rare. Inside a turn the read comes from the
70
+ # ConfigStore cache. A provider this method applied and that no longer has a
71
+ # key (deleted, or its key cleared) is unapplied; a key it never applied, such
72
+ # as one from the environment, is never cleared. A store error keeps the
73
+ # current config: the next call retries, and the chat is not failed for it.
74
+ def refresh
75
+ records = @provider_store.all_raw
76
+ digest = records.hash
77
+ return if digest == @refreshed_digest
78
+
79
+ applied = apply(records)[:applied].map(&:to_s)
80
+ (Array(@refreshed_apis) - applied).each { |api| unapply(api) }
81
+ @refreshed_apis = applied
82
+ @refreshed_digest = digest
83
+ rescue StandardError => e
84
+ warn "[insika] LLM providers not refreshed, keeping the current ones: #{e.class}: #{e.message}"
85
+ end
86
+
87
+ # UNDOES a provider's config in RubyLLM at runtime (delete without a restart):
88
+ # `<api>_api_key`/`<api>_api_base` go back to what they were before this
89
+ # configurator first set them (nil when nothing was). A provider that RubyLLM
66
90
  # doesn't recognize (no accessor) -> unapplied: false (nothing applied, nothing to undo).
67
91
  # -> { unapplied: bool }.
68
92
  def unapply(api)
69
93
  api = api.to_s
94
+ # never applied here (another worker served the edit): nothing of ours to undo
95
+ return { unapplied: false } unless boot_values.key?(api)
96
+
70
97
  unapplied = false
71
98
  with_config do |config|
72
- unapplied = set_accessor(config, "#{api}_api_key", nil)
73
- set_accessor(config, "#{api}_api_base", nil)
99
+ key, base = boot_values[api]
100
+ unapplied = set_accessor(config, "#{api}_api_key", key)
101
+ set_accessor(config, "#{api}_api_base", base)
74
102
  end
75
103
  { unapplied: unapplied }
76
104
  end
@@ -86,6 +114,20 @@ module Insika
86
114
  end
87
115
  end
88
116
 
117
+ # The key/base a provider had before this configurator first overrode it (the
118
+ # environment's, or nil), so removing the authored one puts those back instead
119
+ # of leaving the provider without credentials.
120
+ def remember_boot(config, api)
121
+ return if boot_values.key?(api.to_s)
122
+
123
+ boot_values[api.to_s] = %w[key base].map do |part|
124
+ getter = "#{api}_api_#{part}"
125
+ config.public_send(getter) if config.respond_to?(getter)
126
+ end
127
+ end
128
+
129
+ def boot_values = (@boot_values ||= {})
130
+
89
131
  def set_accessor(config, name, value)
90
132
  setter = "#{name}="
91
133
  return false unless config.respond_to?(setter)
@@ -38,9 +38,7 @@ module Insika
38
38
  end
39
39
 
40
40
  # -> [Hash] all with REAL api_key (for the configurator). Never goes to the screen.
41
- def all_raw
42
- apis.filter_map { |a| raw(a) }
43
- end
41
+ def all_raw = @cs.all(SCOPE).compact
44
42
 
45
43
  # Upsert with secret reconciliation. `attrs` (string|symbol keys):
46
44
  # api (required), base_url, auth_header, api_key (sentinel-aware), models[]
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Content-free native diagnostics. Task deletion owns their lifetime.
5
+ class LLMTraceStore
6
+ SCOPE = "llm_traces"
7
+ MAX_PER_TASK = 200
8
+ TEXT_FIELDS = %w[type operation agent provider model status request_id exception_class at].freeze
9
+ NUMBER_FIELDS = %w[turn duration_ms input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens cost].freeze
10
+
11
+ def initialize(store:)
12
+ @store = store
13
+ end
14
+
15
+ # Also used at the telemetry boundary; never stringify arbitrary objects.
16
+ def self.sanitize(entry)
17
+ entry.each_with_object({}) do |(key, value), result|
18
+ if TEXT_FIELDS.include?(key) && value.is_a?(String)
19
+ result[key] = Coercion.utf8(value)[0, 256].gsub(/[[:cntrl:]]/, "")
20
+ elsif NUMBER_FIELDS.include?(key) && (value.nil? ||
21
+ ((value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value >= 0 && value <= 1e18))
22
+ result[key] = value
23
+ end
24
+ end
25
+ end
26
+
27
+ def record(task_id:, entry:) = record_many(task_id: task_id, entries: [entry])
28
+
29
+ # A turn's events in one transaction: one read and one write of the trace,
30
+ # instead of one transaction per event.
31
+ def record_many(task_id:, entries:)
32
+ return if entries.empty?
33
+
34
+ @store.transaction do
35
+ next unless @store.get(TaskStore::SCOPE, "#{TaskStore::KEY_PREFIX}#{task_id}")
36
+
37
+ trace = for_task(task_id)
38
+ all = trace["entries"] + entries.map { |entry| self.class.sanitize(entry) }
39
+ @store.set(SCOPE, task_id.to_s, {
40
+ "entries" => all.last(MAX_PER_TASK),
41
+ "truncated" => trace["truncated"] || all.size > MAX_PER_TASK
42
+ })
43
+ end
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def for_task(task_id)
49
+ @store.get(SCOPE, task_id.to_s) || { "entries" => [], "truncated" => false }
50
+ end
51
+
52
+ def clear(task_id) = @store.delete(SCOPE, task_id.to_s)
53
+ end
54
+ end