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
@@ -17,6 +17,10 @@ module Insika
17
17
  # tools rewritten; the allowlists (prompt_files/skills/tools_allow) are
18
18
  # AUTHORITATIVE from the pack, so whatever left the pack leaves the agent
19
19
  # (isolation and no drift). An in-flight turn keeps the profile it captured.
20
+ #
21
+ # `limits` and `params` are the exception, and the only one: they MERGE per key
22
+ # (see KNOB_BAGS). A client that publishes two limits is not saying the agent has
23
+ # only two.
20
24
  class PackImporter
21
25
  def initialize(bus:, profiles:)
22
26
  @bus = bus
@@ -30,13 +34,14 @@ module Insika
30
34
  id = presence(pack.config[:id]) ||
31
35
  (raise Insika::ValidationError, "pack missing config.id")
32
36
 
33
- created = @profiles[id].nil?
37
+ existing = @profiles[id]
38
+ created = existing.nil?
34
39
  # create_agent rejects an already-existing id; update_agent requires it to
35
40
  # exist — the choice by presence makes the import an upsert.
36
- dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id))
41
+ dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id, existing))
37
42
 
38
43
  pack.files.each { |name, body| dispatch(:write_agent_file, { agent_id: id, file: name, content: body }) }
39
- pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body }) }
44
+ pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body, agent: id }) }
40
45
  pack.tools.each { |defn| dispatch(:write_data_tool, defn) }
41
46
 
42
47
  { agent_id: id, created: created,
@@ -44,8 +49,8 @@ module Insika
44
49
  end
45
50
 
46
51
  # Removes the agent (delete_agent). Does NOT delete skills/tools/files: they
47
- # may be shared and the SkillStore/ToolStore are global — selective removal is
48
- # operator work. NotFoundError (missing agent) propagates -> 404.
52
+ # may be needed again on re-import; tools may also be shared. Selective removal
53
+ # is operator work. NotFoundError (missing agent) propagates -> 404.
49
54
  def delete(id)
50
55
  dispatch(:delete_agent, { id: id })
51
56
  { agent_id: id, deleted: true }
@@ -58,8 +63,10 @@ module Insika
58
63
  # its OWN pack (per-store isolation) and re-provisioning removes what left.
59
64
  # - prompt_files = the pack's .md files (write_agent_file also registers;
60
65
  # union is a no-op). Setting here makes the list authoritative (removes the ones that left).
61
- # - skills = the pack's skills/ dirs (explicit allowlist; [] when the pack
62
- # has none — never nil=all, which would leak skills from other stores).
66
+ # - skills = the pack's skills/ dirs ∪ config.skills (names of SHARED skills the
67
+ # pack references without copying — one body serves every agent that lists
68
+ # it). [] when both are empty — never nil=all, which would leak skills from
69
+ # other stores.
63
70
  # - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
64
71
  # the agent can call its own data-tools. [] when neither exists —
65
72
  # never nil=all: the ToolStore is GLOBAL, so a tool-less pack would
@@ -70,11 +77,12 @@ module Insika
70
77
  # the tools of the enabled groups (union with tools_allow) go to the model;
71
78
  # those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
72
79
  # tool-call waste, where the flag only exists in Rails).
73
- def agent_attrs(pack, id)
80
+ def agent_attrs(pack, id, existing = nil)
74
81
  attrs = pack.config.dup
75
82
  attrs[:id] = id
83
+ merge_knob_bags!(attrs, existing)
76
84
  attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
77
- attrs[:skills] = pack.skills.keys
85
+ attrs[:skills] = pack.skills.keys | Array(pack.config[:skills]).map(&:to_s)
78
86
 
79
87
  pack_tools = pack.tools.map { |t| tool_name(t) }
80
88
  allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
@@ -85,6 +93,43 @@ module Insika
85
93
  attrs
86
94
  end
87
95
 
96
+ # The bags a re-import must not ERASE. `limits` and `params` are flat
97
+ # `key => scalar` maps whose keys are independent of one another: a provisioning
98
+ # client that sends two of them is saying something about those two and nothing
99
+ # about the rest. `update_agent` replaces a sent field wholesale (correct for the
100
+ # Studio, which always posts the complete bag), so without this a client that
101
+ # publishes `limits: {turn_timeout:, context_budget:}` silently drops every knob
102
+ # an operator set here — `queue_mode`, `debounce_ms`, `chat_rate_limit` — on the
103
+ # next publish of an unrelated playbook.
104
+ #
105
+ # NOT the authoritative list (`prompt_files`/`skills`/`tools_allow`), which the
106
+ # pack is supposed to erase: those are isolation boundaries and a name that left
107
+ # the pack must leave the agent. A knob is not a boundary.
108
+ #
109
+ # The pack still WINS per key. What it cannot do is remove a key by omission —
110
+ # removing a knob is Studio (or operator) work, which is where it was set.
111
+ KNOB_BAGS = %i[limits params].freeze
112
+
113
+ # Keys are symbolized on BOTH sides before merging: the profile holds symbols and
114
+ # the pack's nested hashes arrive with whatever the JSON had, so a raw merge would
115
+ # leave `:turn_timeout` and `"turn_timeout"` side by side and let the later
116
+ # normalization pick by insertion order.
117
+ def merge_knob_bags!(attrs, existing)
118
+ return if existing.nil?
119
+
120
+ KNOB_BAGS.each do |bag|
121
+ patch = attrs[bag]
122
+ next unless patch.is_a?(Hash)
123
+
124
+ current = existing.respond_to?(bag) ? existing.public_send(bag) : nil
125
+ next unless current.is_a?(Hash)
126
+
127
+ attrs[bag] = sym_keys(current).merge(sym_keys(patch))
128
+ end
129
+ end
130
+
131
+ def sym_keys(hash) = hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
132
+
88
133
  # PER-FLAG CUT (/, STATIC pilot): derives the agent's
89
134
  # per-group allowlist from FLAGS declared in the pack config — DATA, never a
90
135
  # core convention. The engine doesn't know "groceries_v2"/"b2b": the pack
@@ -55,7 +55,10 @@ module Insika
55
55
 
56
56
  # The never-ship set: the studio JS toolchain (only assets/dist ships) and
57
57
  # the docs' own Jekyll Gemfile/config.
58
- NEVER_SHIP_PREFIXES = ["node_modules", "lib/insika/studio/test/", "lib/insika/studio/assets/src/"].freeze
58
+ # docs/superpowers/ holds internal plans (customer names, live results): never
59
+ # tracked here any more, and never shipped even if one is committed by mistake.
60
+ NEVER_SHIP_PREFIXES = ["node_modules", "lib/insika/studio/test/", "lib/insika/studio/assets/src/",
61
+ "docs/superpowers/"].freeze
59
62
  NEVER_SHIP_PATHS = %w[
60
63
  docs/Gemfile docs/Gemfile.lock docs/_config.yml
61
64
  lib/insika/studio/README.md lib/insika/studio/package.json
@@ -97,14 +100,20 @@ module Insika
97
100
  # bracket classes collapsed), so the corpus data's `voc[êe]`-style
98
101
  # patterns are caught by the same table.
99
102
  def domain_content?(path)
100
- text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
101
- fold(text).match?(PT_BR_TOKENS)
103
+ fold(payload_text(path)).match?(PT_BR_TOKENS)
102
104
  end
103
105
 
104
106
  # -> bool: does a payload file mention the demo persona name (`bia`)?
105
107
  def persona_content?(path)
106
- text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
107
- text.match?(PERSONA_NAME)
108
+ payload_text(path).match?(PERSONA_NAME)
109
+ end
110
+
111
+ # -> String: the text the audit scans. Ruby goes through `ruby_text`;
112
+ # everything else is read as-is with invalid UTF-8 DROPPED — the payload
113
+ # also carries binary assets (the Studio's embedded webfonts), which hold
114
+ # no readable content but would otherwise raise on the first match.
115
+ def payload_text(path)
116
+ path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path).scrub("")
108
117
  end
109
118
 
110
119
  # The Ruby source as the audit reads it: heredoc bodies verbatim, string +
@@ -20,18 +20,30 @@ module Insika
20
20
 
21
21
  STATUSES = %i[pending approved rejected].freeze
22
22
 
23
- PendingAction = Data.define(:id, :task_id, :turn, :tool, :args,
23
+ # Who resolves the action: the OPERATOR in the Studio (the turn suspended and
24
+ # waits) or the CUSTOMER on the next message (the turn ended with a question).
25
+ # Absent on a stored row = operator, so rows written before `kind` keep meaning.
26
+ OPERATOR = "operator"
27
+ CUSTOMER = "customer"
28
+
29
+ PendingAction = Data.define(:id, :task_id, :session_id, :turn, :tool, :args, :kind,
24
30
  :status, :requested_at, :resolved_by, :resolved_at)
25
31
 
32
+ # One row per (session, tool) for a customer hold: a crash-and-re-execute of the
33
+ # holding turn rewrites the same row instead of leaving two open.
34
+ def self.confirmation_id(session_id, tool) = "confirm:#{session_id}:#{tool}"
35
+
26
36
  def initialize(store:)
27
37
  @store = store
28
38
  end
29
39
 
30
40
  # -> PendingAction (:pending). `args` is the Hash of the tool call's arguments.
31
- def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
41
+ def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid, kind: OPERATOR, session_id: nil)
32
42
  record = {
33
43
  "id" => id.to_s,
34
44
  "task_id" => task_id.to_s,
45
+ "session_id" => session_id&.to_s,
46
+ "kind" => kind.to_s,
35
47
  "turn" => turn,
36
48
  "tool" => tool.to_s,
37
49
  "args" => deep_stringify(args),
@@ -52,29 +64,31 @@ module Insika
52
64
 
53
65
  # -> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node,
54
66
  # like TaskStore#running_or_interrupted.
55
- def open_for(task_id)
67
+ def open_for(task_id, kind: nil)
56
68
  id = task_id.to_s
57
- @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
58
- record = @store.get(SCOPE, key)
59
- next if record.nil?
69
+ scan_open(kind).select { |pa| pa.task_id == id }
70
+ end
60
71
 
61
- pa = to_pending(record)
62
- pa if pa.status == :pending && pa.task_id == id
63
- end
72
+ # -> [PendingAction] :pending for the session — the customer's holds live here,
73
+ # keyed by the conversation rather than the task that held them.
74
+ def open_for_session(session_id, kind: nil)
75
+ id = session_id.to_s
76
+ scan_open(kind).select { |pa| pa.session_id == id }
77
+ end
78
+
79
+ # Customer holds of a session that an EARLIER task created and nothing
80
+ # resolved: the next message did not agree, so the engine rejects them.
81
+ # -> [PendingAction] the ones it expired.
82
+ def expire_customer_holds(session_id:, except_task_id:)
83
+ keep = except_task_id.to_s
84
+ open_for_session(session_id, kind: CUSTOMER).reject { |pa| pa.task_id == keep }
85
+ .map { |pa| resolve(pa.id, decision: :rejected, operator: "engine:expired") }
64
86
  end
65
87
 
66
88
  # -> [PendingAction] every :pending across all tasks — the approvals inbox
67
89
  # (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
68
90
  # UI resolves task context afterwards via TaskStore#find.
69
- def all_open
70
- @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
71
- record = @store.get(SCOPE, key)
72
- next if record.nil?
73
-
74
- pa = to_pending(record)
75
- pa if pa.status == :pending
76
- end
77
- end
91
+ def all_open(kind: nil) = scan_open(kind)
78
92
 
79
93
  # -> resolved PendingAction. Only resolves :pending: a double resolution
80
94
  # or an invalid decision -> ValidationError; absent -> NotFoundError.
@@ -101,13 +115,26 @@ module Insika
101
115
 
102
116
  def key_for(id) = "#{KEY_PREFIX}#{id}"
103
117
 
118
+ def scan_open(kind)
119
+ wanted = kind&.to_s
120
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
121
+ record = @store.get(SCOPE, key)
122
+ next if record.nil?
123
+
124
+ pa = to_pending(record)
125
+ pa if pa.status == :pending && (wanted.nil? || pa.kind == wanted)
126
+ end
127
+ end
128
+
104
129
  def to_pending(record)
105
130
  PendingAction.new(
106
131
  id: record["id"],
107
132
  task_id: record["task_id"],
133
+ session_id: record["session_id"],
108
134
  turn: record["turn"],
109
135
  tool: record["tool"],
110
136
  args: record["args"],
137
+ kind: record["kind"] || OPERATOR,
111
138
  status: record["status"].to_sym,
112
139
  requested_at: record["requested_at"],
113
140
  resolved_by: record["resolved_by"],
@@ -78,8 +78,9 @@ module Insika
78
78
  @announced_roots.any? { |root| dir == root || dir.start_with?("#{root}#{File::SEPARATOR}") }
79
79
  end
80
80
 
81
- # One manifest per directory: insika.plugin.yml takes precedence over
81
+ # One manifest per directory: insika.plugin.yml takes precedence over a leftover
82
82
  # plugin.yml (same dir). Order preserved (root precedence — first wins via `seen`).
83
+ # plugin.yml is still globbed only so load_manifest can refuse it out loud.
83
84
  def manifest_files
84
85
  selected = {}
85
86
  @roots.each do |root|
@@ -96,7 +97,8 @@ module Insika
96
97
  return nil unless manifest.is_a?(Hash)
97
98
 
98
99
  if File.basename(file) == "plugin.yml"
99
- warn "[plugin #{manifest['id']}] plugin.yml is deprecated — rename to insika.plugin.yml"
100
+ warn "[plugin #{manifest['id']}] plugin.yml was removed in 1.0 and is not loaded — rename to insika.plugin.yml"
101
+ return nil
100
102
  end
101
103
  manifest
102
104
  rescue StandardError => e
@@ -9,47 +9,52 @@ module Insika
9
9
  # (name + description + parameters.inspect, executor.rb:957). Outputs are
10
10
  # PII-free digests: it never sees message text, only hashes leave this class.
11
11
  # Pure stdlib (digest/sha2), no gem, no IO.
12
+ #
13
+ # The chain follows the cache boundary: the cumulative "prefix" is over the
14
+ # IDENTITY categories + tool_schemas only — the bytes the cache breakpoint
15
+ # actually covers. Volatile categories (memory, knowledge, briefing, request)
16
+ # render below the breakpoint, so a change there is not a prefix invalidation;
17
+ # they still get their own digest (the trace shows them) but sit AFTER the
18
+ # "prefix" key, outside the chain.
12
19
  class PrefixFingerprint
13
20
  # -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
14
- # "prefix" => "sha256…" }
15
- # Keys are the demodulized, downcased provider ids, in render order.
21
+ # "prefix" => "sha256…", "memory" => "sha256…", … }
22
+ # Keys are the demodulized, downcased provider ids. Order: identity
23
+ # categories (render order), "tool_schemas", "prefix", then the volatile
24
+ # categories — everything before "prefix" is the chain.
16
25
  # A category with no fragments is absent (not an empty hash).
17
26
  # The category digest = SHA256 of the category's fragments joined "\n\n"
18
27
  # (the Builder's separator — the digest matches rendered bytes).
19
- # "prefix" = SHA256 of the category digests concatenated IN CHAIN ORDER
20
- # (identity categories, then volatile, then tool_schemas) — any divergence
21
- # anywhere above the boundary changes it.
28
+ # "prefix" = SHA256 of the chain digests concatenated in order — any
29
+ # divergence anywhere above the boundary changes it. A fragment with no
30
+ # layer stamp reads as volatile, like everywhere else.
22
31
  def self.compute(system_fragments, tool_serial:)
23
- digests = {} # category name -> digest, insertion = render order
24
- grouped = system_fragments.group_by { |f| category(f.source) }
25
- grouped.each do |name, frags|
26
- digests[name] = digest(frags.map(&:content).join("\n\n"))
27
- end
32
+ identity, volatile = system_fragments.partition { |f| (f.layer || :volatile) == :identity }
33
+ digests = category_digests(identity)
28
34
  digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
29
35
  digests["prefix"] = digest(digests.values.join) unless digests.empty?
30
- digests
36
+ digests.merge(category_digests(volatile))
31
37
  end
32
38
 
33
- # -> String | nil. The first category (in CURRENT chain order) whose digest
34
- # differs or is absent from `previous`; else the first PREVIOUS key now
35
- # absent from the current chain (a vanished block is a divergence too);
36
- # else nil. nil `previous` (first turn) -> nil. The returned name is a
37
- # category id — PII-free by construction.
38
- #
39
- # The cumulative "prefix" key is deliberately SKIPPED in the scan: its
40
- # digest changes whenever ANY category moves, so scanning it would shadow a
41
- # vanished block (every surviving category matches, "prefix" differs, and
42
- # the vanished-fallback below becomes unreachable — reporting `broke:
43
- # prefix` instead of the category that actually left).
39
+ # -> String | nil. nil when the cumulative "prefix" did not move (a
40
+ # volatile change is not an invalidation). Else the first chain category
41
+ # (in CURRENT chain order) whose digest differs or is absent from
42
+ # `previous`; else the first PREVIOUS chain key now absent from the current
43
+ # chain (a vanished block is a divergence too). nil `previous` (first turn)
44
+ # -> nil. The returned name is a category id — PII-free by construction.
44
45
  def self.invalidation_reason(current, previous)
45
46
  return nil unless previous.is_a?(Hash)
47
+ return nil if current["prefix"] == previous["prefix"]
46
48
 
47
- current.each_key do |name|
48
- next if name == "prefix"
49
+ chain(current).find { |name| previous[name] != current[name] } ||
50
+ (chain(previous) - current.keys).first
51
+ end
52
+
53
+ def self.chain(map) = map.keys.take_while { |k| k != "prefix" }
49
54
 
50
- return name unless previous[name] == current[name]
51
- end
52
- (previous.keys - current.keys).first
55
+ def self.category_digests(fragments)
56
+ fragments.group_by { |f| category(f.source) }
57
+ .transform_values { |frags| digest(frags.map(&:content).join("\n\n")) }
53
58
  end
54
59
 
55
60
  def self.category(source) = source.to_s.split("::").last.to_s.downcase
@@ -98,23 +98,28 @@ module Insika
98
98
  tools_allow_groups: h[:tools_allow_groups],
99
99
  skills: h[:skills],
100
100
  skills_eager: h[:skills_eager],
101
+ kits: h[:kits],
101
102
  context_providers: h[:context_providers],
102
103
  workflows_allow: h[:workflows_allow],
103
104
  policies: Array(h[:policies]).map(&:to_sym),
104
105
  prompt_refs: h[:prompt_refs] || [],
105
106
  limits: symbolize_limits(h[:limits]),
106
107
  approvals_required: h[:approvals_required],
108
+ customer_confirm: h[:customer_confirm],
107
109
  capabilities: h[:capabilities],
108
110
  # subagents: allowlist of child ids; build re-normalizes to
109
111
  # [String]. nil round-trips as nil (opt-in: NONE).
110
112
  subagents: h[:subagents],
111
113
  tools_deferred: h[:tools_deferred],
112
114
  memory: h[:memory],
115
+ memory_retrieval: h[:memory_retrieval],
116
+ shared_conversations: h[:shared_conversations],
113
117
  prompt_caching: h[:prompt_caching],
114
118
  # tool_persistence: the one opt-out flag — nil round-trips as nil (= ON);
115
119
  # only a stored explicit false turns the discipline block off.
116
120
  tool_persistence: h[:tool_persistence],
117
121
  tool_output_compression: h[:tool_output_compression],
122
+ fencing: h[:fencing],
118
123
  # params/model_policy: the resolver tolerates string keys from
119
124
  # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
120
125
  # re-symbolization needed here.
@@ -7,11 +7,12 @@ module Insika
7
7
  # poisoned credential; a fatal is retried only after the operator fixes the
8
8
  # cause):
9
9
  #
10
- # :fatal 401/402/403/400 (auth, billing, permanent quota,
11
- # bad request, context too long) — retrying does not help.
10
+ # :fatal 401/402/403/400 (auth, billing, bad request,
11
+ # context too long) — retrying does not help.
12
12
  # :retryable 5xx/529/socket/timeout — the same call may succeed
13
13
  # moments later.
14
- # :rate_limited_short a 429 that says "back off briefly" (RPM-scale).
14
+ # :rate_limited_short a 429 that says "back off briefly" (RPM-scale), or a
15
+ # 400 quota message RubyLLM raises as RateLimitError.
15
16
  # :rate_limited_long a 429 with a long retry-after — quota-scale.
16
17
  #
17
18
  # The classification is STRING-based (class names, no constant references):
@@ -98,6 +99,20 @@ module Insika
98
99
  (names & RUBY_LLM_ERROR_NAMES).any? || transport?(names)
99
100
  end
100
101
 
102
+ # :fatal for THIS node only: the account cannot pay (402), the key is wrong
103
+ # or lacks access (401/403), or the model does not exist there (404).
104
+ # Retrying the node never helps, but another node — another account,
105
+ # another model — may answer, so Reliability rotates instead of killing the
106
+ # turn. A 400 stays out: a malformed request fails on any node.
107
+ NODE_FATAL_ERROR_NAMES = %w[
108
+ RubyLLM::UnauthorizedError RubyLLM::PaymentRequiredError RubyLLM::ForbiddenError
109
+ ].freeze
110
+ NODE_FATAL_STATUSES = [401, 402, 403, 404].freeze
111
+
112
+ def node_fatal?(error)
113
+ (class_names(error) & NODE_FATAL_ERROR_NAMES).any? || NODE_FATAL_STATUSES.include?(http_status(error))
114
+ end
115
+
101
116
  # The typed ProviderError the executor stores and emits.
102
117
  def wrap(error)
103
118
  c = classify(error)
@@ -24,7 +24,7 @@ module Insika
24
24
  #
25
25
  # session vars["queue_mode"] — one conversation pinned by an operator
26
26
  # profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
27
- # settings["queue"][<key>] — platform default, editable in the Studio
27
+ # settings["queue"][<key>] — platform default (Studio > Settings > Burst)
28
28
  # DEFAULTS[<key>] — = today's behavior
29
29
  #
30
30
  # Every default is off: a bare wiring behaves exactly as it did before this
@@ -126,9 +126,14 @@ module Insika
126
126
  # "inherit the platform default". Same semantics EdgeLimiter documents at
127
127
  # `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
128
128
  # explicit null must not silently re-enable a platform behavior.
129
+ #
130
+ # The PLATFORM layer reads nil the other way round: its form (Studio > Settings >
131
+ # Burst) writes nil for every blank field, and blank there means "no platform
132
+ # default", not 0 — a blank `steer_max_messages` must not become "never steer"
133
+ # for the whole deploy. An agent still says "off" with an explicit 0.
129
134
  def self.pick(key, limits, platform)
130
135
  return limits[key].to_i if limits.key?(key)
131
- return platform[key.to_s].to_i if platform.key?(key.to_s)
136
+ return platform[key.to_s].to_i unless platform[key.to_s].nil?
132
137
 
133
138
  DEFAULTS[key]
134
139
  end
@@ -38,11 +38,32 @@ module Insika
38
38
 
39
39
  MAX_FINDINGS = 10
40
40
 
41
+ RESPONSE_SCHEMA = {
42
+ "type" => "object",
43
+ "properties" => {
44
+ "rationale" => { "type" => "string" },
45
+ "edits" => {
46
+ "type" => "array",
47
+ "items" => {
48
+ "type" => "object",
49
+ "properties" => {
50
+ "file" => { "type" => "string" }, "op" => { "type" => "string", "enum" => %w[replace append] },
51
+ "anchor" => { "type" => "string" }, "before" => { "type" => "string" },
52
+ "after" => { "type" => "string" },
53
+ "addresses" => { "type" => "array", "items" => { "type" => "string" } }
54
+ },
55
+ "required" => %w[file op after]
56
+ }
57
+ }
58
+ },
59
+ "required" => ["edits"]
60
+ }.freeze
61
+
41
62
  # The model ref, so a panel can name WHICH proposer failed without guessing.
42
63
  attr_reader :model
43
64
 
44
65
  # ask: ->(prompt) { "<raw model text>" }, or something answering `#content`
45
- # plus `#input_tokens`/`#output_tokens` (a RubyLLM message). The second
66
+ # plus RubyLLM-compatible `#tokens`. The second
46
67
  # shape is what lets the panel's budget count what a proposal cost; a
47
68
  # plain String stays valid and simply reports no cost, which is what
48
69
  # every existing caller and every fake does.
@@ -63,7 +84,7 @@ module Insika
63
84
  raise Unusable, "no writable file has any content to anchor an edit in" if files.empty?
64
85
 
65
86
  answer = @ask.call(build_prompt(agent_id, Array(findings).first(MAX_FINDINGS), files, limits))
66
- parsed = parse(text_of(answer))
87
+ parsed = parse(answer)
67
88
  parsed["proposer"] = @model
68
89
  parsed["tokens"] = tokens_of(answer)
69
90
  parsed["cached"] = cached_of(answer)
@@ -80,29 +101,33 @@ module Insika
80
101
  # reason `Evals::Runner#billed_tokens` includes it: a ceiling that cannot see
81
102
  # what the cache served is not a ceiling on what was sent.
82
103
  def tokens_of(answer)
83
- return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
104
+ return nil unless answer.respond_to?(:tokens)
84
105
 
85
- total = answer.input_tokens.to_i + answer.output_tokens.to_i + cached_of(answer).to_i
106
+ usage = answer.tokens
107
+ total = usage.input.to_i + usage.output.to_i + usage.cache_read.to_i + usage.cache_write.to_i
86
108
  total.positive? ? total : nil
87
109
  end
88
110
 
89
111
  def cached_of(answer)
90
- return nil unless answer.respond_to?(:cached_tokens)
112
+ return nil unless answer.respond_to?(:tokens)
91
113
 
92
- cached = answer.cached_tokens.to_i
114
+ cached = answer.tokens.cache_read.to_i
93
115
  cached.positive? ? cached : nil
94
116
  end
95
117
 
96
- # JSON or nothing. ````json` fences are the common wrapper and stripping them is
97
- # not leniency — the payload inside is still parsed strictly, so a model that
98
- # improvises a schema fails here instead of producing half a candidate.
99
- def parse(raw)
100
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
101
- first = body.index("{")
102
- last = body.rindex("}")
103
- raise Unusable, "the proposer answered with no JSON object" if first.nil? || last.nil? || last < first
104
-
105
- parsed = JSON.parse(body[first..last])
118
+ # Native responses own JSON parsing. Only injected text asks retain the
119
+ # existing prose/fence extraction contract.
120
+ def parse(answer)
121
+ if answer.respond_to?(:parsed)
122
+ parsed = answer.parsed
123
+ else
124
+ body = text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
125
+ first = body.index("{")
126
+ last = body.rindex("}")
127
+ raise Unusable, "the proposer answered with no JSON object" if first.nil? || last.nil? || last < first
128
+
129
+ parsed = JSON.parse(body[first..last])
130
+ end
106
131
  raise Unusable, "the proposer's JSON is not an object" unless parsed.is_a?(Hash)
107
132
  raise Unusable, "the proposer's JSON carries no `edits`" unless parsed["edits"].is_a?(Array)
108
133
 
@@ -254,7 +279,7 @@ module Insika
254
279
  llm ||= RubyLLM
255
280
  lambda do |prompt|
256
281
  llm.chat(model: model, provider: provider, assume_model_exists: true)
257
- .with_temperature(0).ask(prompt)
282
+ .with_temperature(0).with_schema("schema" => Proposer::RESPONSE_SCHEMA, "strict" => false).ask(prompt)
258
283
  end
259
284
  end
260
285
  end
@@ -6,8 +6,9 @@ module Insika
6
6
  # the circuit breaker — all as DATA on `AgentProfile#reliability`, never a
7
7
  # parallel code path. The failure classification is B9's
8
8
  # (ProviderErrorClassifier): ONLY :retryable / :rate_limited_* ever retry or
9
- # rotate; a :fatal is re-raised immediately (a poisoned credential must not
10
- # hammer N models).
9
+ # rotate; a :fatal is re-raised immediately (a malformed request fails on any
10
+ # model) — unless it is fatal only for that node (402/401/403/404:
11
+ # `ProviderErrorClassifier.node_fatal?`), which skips to the next node.
11
12
  #
12
13
  # Each ATTEMPT is a fresh chat built by the caller (the coordinator yields the
13
14
  # selection): a failed `ask` leaves its user message in the chat, so re-asking
@@ -100,7 +101,15 @@ module Insika
100
101
  # never rotated).
101
102
  retryable = retryable_failure?(e)
102
103
  raise unless retryable
103
- raise if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
104
+ if kind_of(e) == :fatal && !e.is_a?(Insika::TimeoutError)
105
+ # Fatal for THIS node only (no balance, bad key, unknown model):
106
+ # skip to the next node at once — no retry, no backoff, no breaker
107
+ # bump (an open primary circuit fails fast WITHOUT rotating).
108
+ raise unless ProviderErrorClassifier.node_fatal?(e) && node != nodes.last
109
+
110
+ record_failure(tenant, selection, nil, e, agent)
111
+ break
112
+ end
104
113
 
105
114
  record_failure(tenant, selection, breaker, e, agent)
106
115
  # the last attempt of the last node re-raises; otherwise back off
@@ -49,7 +49,7 @@ module Insika
49
49
  return health_response if req.request_method == "GET" && segments == ["up"]
50
50
 
51
51
  raw_body = read_body(env)
52
- backend = pick_backend(req.request_method, segments, raw_body)
52
+ backend = pick_backend(req, segments, raw_body)
53
53
  return unavailable_response if backend.nil?
54
54
 
55
55
  proxy(req, backend, raw_body)
@@ -57,18 +57,20 @@ module Insika
57
57
 
58
58
  private
59
59
 
60
- def pick_backend(method, segments, raw_body)
60
+ def pick_backend(req, segments, raw_body)
61
61
  backends = @pool.backends
62
62
  return nil if backends.empty?
63
63
 
64
- key = session_key(method, segments, raw_body)
64
+ key = session_key(req, segments, raw_body)
65
65
  return @pool.ring.backend_for(key) if key
66
66
 
67
67
  @rr_index = (@rr_index + 1) % backends.size
68
68
  backends[@rr_index]
69
69
  end
70
70
 
71
- def session_key(method, segments, raw_body)
71
+ def session_key(req, segments, raw_body)
72
+ method = req.request_method
73
+ return SessionKey.extract(method, segments, body: -> {}, query: req.GET) if method == "GET"
72
74
  return nil if raw_body.nil?
73
75
 
74
76
  if raw_body.bytesize > @body_max_bytes
@@ -77,7 +79,14 @@ module Insika
77
79
  return nil
78
80
  end
79
81
 
80
- SessionKey.extract(method, segments, body: -> { JSON.parse(raw_body) })
82
+ SessionKey.extract(method, segments, body: -> { parse_body(req, raw_body) })
83
+ end
84
+
85
+ # JSON for the machine routes; the Studio's forms post urlencoded.
86
+ def parse_body(req, raw_body)
87
+ return Rack::Utils.parse_query(raw_body) if req.media_type == "application/x-www-form-urlencoded"
88
+
89
+ JSON.parse(raw_body)
81
90
  end
82
91
 
83
92
  # Reads the WHOLE body (it must be forwarded intact) — `body_max_bytes`