insika 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -42,9 +42,23 @@ module Insika
42
42
  message = extract_input(body[:input])
43
43
 
44
44
  out = { agent: agent.strip, user: user, message: message }
45
+ if body.key?(:shared_conversation)
46
+ raise Insika::ValidationError, 'shared_conversation must be an object and user_text a string' unless body[:shared_conversation].is_a?(Hash) && body[:user_text].is_a?(String)
47
+ out[:shared_conversation] = body[:shared_conversation]
48
+ out[:user_text] = body[:user_text]
49
+ end
45
50
  (origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
51
+ # the caller's text that rides ahead of `input` to the model; guardrails read `input` alone
52
+ unless body[:context].nil? || body[:context].is_a?(String)
53
+ raise Insika::ValidationError, "context must be a string"
54
+ end
55
+ out[:context] = body[:context] if body[:context]
46
56
  # WS8: the optional customer_key — per-customer memory scope + purge handle
47
57
  (customer = Insika::Coercion.presence(body[:customer])) && (out[:customer] = customer)
58
+ # the optional labels the Studio shows and searches the conversation by
59
+ Insika::SessionStore::CUSTOMER_LABELS.each do |key|
60
+ out[key.to_sym] = body[key.to_sym] if body[key.to_sym].is_a?(String)
61
+ end
48
62
  # WS9: the multimodal OPENAI shape — `input` as an array of content parts
49
63
  # ({type: text/image/audio}) is preserved additively alongside the
50
64
  # joined text; a string input stays byte-identical to before. The
@@ -197,7 +211,11 @@ module Insika
197
211
  # `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
198
212
  # usage (pure tokens in usage).
199
213
  model = usage[:model] || usage["model"]
200
- response[:usage] = usage.reject { |k, _| k.to_s == "model" }
214
+ projected = usage.transform_keys(&:to_sym).reject { |k, _| k == :model }
215
+ cache = projected[:cached_tokens].to_i + projected[:cache_creation_tokens].to_i
216
+ projected[:input_tokens] += cache if projected[:input_tokens]
217
+ projected[:total_tokens] += cache if projected[:total_tokens]
218
+ response[:usage] = projected
201
219
  response[:model] = model if model
202
220
  end
203
221
  # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
@@ -23,28 +23,47 @@ module Insika
23
23
  # would duplicate the invariant; putting it in the Executor would mix turn
24
24
  # execution with queue policy.
25
25
  class SessionActor
26
- def initialize(session_id:, executor:, parent: Async::Task.current)
26
+ # `on_idle` is called with the actor once its queue drains and the loop ends, so
27
+ # the owner can drop it: an idle session holds no fiber.
28
+ def initialize(session_id:, executor:, parent: Async::Task.current, on_idle: nil)
27
29
  @session_id = session_id
28
30
  @executor = executor
31
+ @on_idle = on_idle
29
32
  @queue = Async::Queue.new
30
33
  @running = false
31
- # The turn currently sitting at the door: created and :queued, but not yet
32
- # released to run. `collect` merges into THIS one. nil whenever there is
33
- # nothing mergeable — which is the common case and the safe default.
34
- @pending = nil
34
+ # The turns sitting at the door: created and :queued, but not yet released to
35
+ # run — whether waiting out a debounce window or simply queued behind the turn
36
+ # in flight. `collect` merges into the LAST one (the turn that has not
37
+ # answered yet; appending to an older one would put a newer fragment in an
38
+ # older message). Keyed by task so a turn released from the FIFO still finds
39
+ # its OWN count — with a single slot, a second turn enqueued behind the first
40
+ # would take the first's fragments with it. Empty is the common case and the
41
+ # safe default: a policy that does not merge never opens one.
42
+ @doors = {}
35
43
  @loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
36
44
  end
37
45
 
38
46
  # Enqueues a turn (FIFO). Non-blocking: the handler responds with an
39
47
  # immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
40
48
  #
41
- # `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
42
- # policy without a window behaves exactly as before — dequeued and run at once.
49
+ # `policy` (a QueuePolicy) decides the door: a merging mode (`collect`/`steer`)
50
+ # opens it here, and a `debounce_ms` on top of that also holds the turn once it
51
+ # reaches the front. A nil policy or `followup` behaves exactly as before —
52
+ # nothing to merge into, dequeued and run at once.
43
53
  #
44
54
  # `timing` is the channel clock a channel turn allocated at 202
45
55
  # acceptance and already stamped `:inbound`; it rides the queue so the debounce
46
56
  # window and the FIFO wait land INSIDE first_balloon_ms.
47
57
  def enqueue(task, profile:, resume_from: nil, policy: nil, timing: nil)
58
+ # The door opens HERE, not when the turn is dequeued. "Created and not yet
59
+ # started" lasts as long as the turn IN FRONT takes, and that whole wait is
60
+ # mergeable: the turn has not spoken, so a fragment appended to it costs
61
+ # nothing and saves an answer. Opening the door only for a debounce window
62
+ # (where it used to live) left every message that arrived while a turn sat in
63
+ # the FIFO with nothing to join — it became a turn of its own behind two
64
+ # others, and a `stream=false` caller then waited for BOTH to run before it
65
+ # heard anything. A window is now what EXTENDS the door, never what creates it.
66
+ @doors[task.id] = open_door(task) if policy&.collect?
48
67
  @queue.enqueue([task, profile, resume_from, policy, timing])
49
68
  task.id
50
69
  end
@@ -58,7 +77,7 @@ module Insika
58
77
  # and neither yields between the check and the write below, so the "is it
59
78
  # still mergeable" test and the append cannot interleave.
60
79
  def collect(text)
61
- pending = @pending
80
+ pending = @doors.values.last
62
81
  return nil if pending.nil?
63
82
 
64
83
  @executor.task_store.append_message(pending[:task_id], text)
@@ -88,15 +107,15 @@ module Insika
88
107
  # request's path.
89
108
  attr_reader :current_task
90
109
 
91
- # Is there a turn at the door that `collect` could still merge into?
92
- def collecting? = !@pending.nil?
110
+ # Is there a turn at the door that `collect` could still merge into? True from
111
+ # the moment a merging policy enqueues one until it starts running.
112
+ def collecting? = !@doors.empty?
93
113
 
94
114
  # Is the loop still alive? (the Executor revalidates before reusing from the
95
115
  # cache — a dead loop would black-hole queued turns).
96
116
  def alive? = !!@loop&.running?
97
117
 
98
- # Shuts down the loop (server shutdown / tests — the loop blocks forever on
99
- # dequeue when idle).
118
+ # Shuts down the loop (server shutdown / tests).
100
119
  def stop = @loop&.stop
101
120
 
102
121
  private
@@ -118,45 +137,56 @@ module Insika
118
137
  @running = false
119
138
  @current_task = nil
120
139
  end
140
+ # Ends with the last queued turn. A parked fiber per session ever seen grew a
141
+ # worker without bound; the next message starts a fresh actor. No yield sits
142
+ # between this check and the loop's end, so an enqueue cannot slip in.
143
+ break if @queue.empty?
121
144
  end
145
+ @on_idle&.call(self)
146
+ nil # the finished task must not hold the actor (see TaskActor#run)
122
147
  end
123
148
 
124
- # the debounce window. Sleeps on the LOOP's fiber, never on the
125
- # request's, so the POST is acked immediately and the platform does not retry.
126
- # Returns the task to run (re-read from the store when fragments merged into it,
127
- # since the in-memory Task is a frozen snapshot of an older message).
149
+ # Closes the turn's door and reports what it caught. The debounce
150
+ # window (when the policy has one) is the EXTRA wait held here, on the LOOP's
151
+ # fiber and never on the request's, so the POST is acked immediately and the
152
+ # platform does not retry. Returns the task to run — re-read from the store when
153
+ # fragments merged into it, since the in-memory Task is a frozen snapshot of an
154
+ # older message.
128
155
  def hold_at_the_door(task, policy)
129
- return task unless policy&.debounce?
156
+ pending = @doors[task.id]
157
+ return task if pending.nil?
130
158
 
131
- @pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
132
159
  begin
133
- wait_for_quiet(policy)
134
- merged = @pending[:count]
135
- arrivals = @pending[:arrivals]
160
+ wait_for_quiet(policy, pending) if policy&.debounce?
161
+ merged = pending[:count]
136
162
  ensure
137
- # The window is closed BEFORE the turn runs, under every exit path: a
138
- # `collect` that slipped in here would append to a task about to be read.
139
- @pending = nil
163
+ # The door closes BEFORE the turn runs, under every exit path: a `collect`
164
+ # that slipped in here would append to a task about to be read.
165
+ @doors.delete(task.id)
140
166
  end
141
167
 
142
168
  return task if merged == 1
143
169
 
144
- @executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
170
+ @executor.emit_coalesced(task, merged: merged, arrivals: pending[:arrivals])
145
171
  @executor.task_store.find(task.id) || task
146
172
  end
147
173
 
174
+ def open_door(task)
175
+ { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
176
+ end
177
+
148
178
  # Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
149
179
  # (`version` moved), until either a slice passes in silence or the total
150
180
  # deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
151
181
  # keeps typing from postponing their own answer forever.
152
- def wait_for_quiet(policy)
182
+ def wait_for_quiet(policy, pending)
153
183
  quiet = policy.debounce_ms / 1000.0
154
184
  deadline = monotonic + (policy.debounce_max_ms / 1000.0)
155
185
 
156
186
  loop do
157
- mark = @pending[:version]
187
+ mark = pending[:version]
158
188
  Async::Task.current.sleep(quiet)
159
- break if @pending[:version] == mark # a full slice of silence
189
+ break if pending[:version] == mark # a full slice of silence
160
190
  break if monotonic >= deadline
161
191
  end
162
192
  end
@@ -20,7 +20,20 @@ module Insika
20
20
  include Coercion
21
21
 
22
22
  SCOPE = "sessions"
23
+ STATS_SCOPE = "session_stats" # one small record per session, see #write
24
+ # Optional Strings a caller sends to label a conversation in the Studio
25
+ # (shown and searched there; no effect on memory scope).
26
+ CUSTOMER_LABELS = %w[customer_name customer_phone].freeze
23
27
  KEY_PREFIX = "session:"
28
+ # Simulator (`sim-<chat>`) and eval (`eval-<id>`) sessions are synthetic
29
+ # traffic: nothing learns from them
30
+ # (knowledge extraction, harvest). A tenant-scoped id ("<tenant>:sim-…")
31
+ # is judged by its last segment.
32
+ SYNTHETIC_PREFIXES = %w[sim- eval-].freeze
33
+
34
+ def self.synthetic?(session_id)
35
+ session_id.to_s.split(":").last.to_s.start_with?(*SYNTHETIC_PREFIXES)
36
+ end
24
37
 
25
38
  Session = Data.define(:id, :messages, :vars, :memory_refs,
26
39
  :created_at, :updated_at, :briefing, :evidence, :compaction) do
@@ -56,7 +69,7 @@ module Insika
56
69
  "created_at" => now,
57
70
  "updated_at" => now
58
71
  }
59
- @store.set(SCOPE, key, record)
72
+ write(record)
60
73
  to_session(record)
61
74
  end
62
75
 
@@ -78,13 +91,18 @@ module Insika
78
91
  # a non-supervised deployment) would interleave read/set and LOSE messages —
79
92
  # there is no compare-and-swap here. Route same-session writes through the
80
93
  # SessionActor; see session_actor.rb.
81
- def append_messages(id, messages)
94
+ # `agent:` names the session's agent when it has none yet (a turn with no
95
+ # customer never stamped one), riding this write instead of one of its own.
96
+ def append_messages(id, messages, agent: nil)
82
97
  record = fetch!(id)
83
98
  incoming = (messages.is_a?(Hash) ? [messages] : Array(messages))
84
99
  .map { |msg| stamp(deep_stringify(msg)) }
85
100
  record["messages"] += incoming
101
+ if agent && Coercion.presence((record["vars"] ||= {})["agent"]).nil?
102
+ record["vars"]["agent"] = agent.to_s
103
+ end
86
104
  record["updated_at"] = timestamp
87
- @store.set(SCOPE, key_for(id), record)
105
+ write(record)
88
106
  to_session(record)
89
107
  end
90
108
 
@@ -94,7 +112,7 @@ module Insika
94
112
  record = fetch!(id)
95
113
  record["vars"] = record["vars"].merge(deep_stringify(vars))
96
114
  record["updated_at"] = timestamp
97
- @store.set(SCOPE, key_for(id), record)
115
+ write(record)
98
116
  to_session(record)
99
117
  end
100
118
 
@@ -111,7 +129,7 @@ module Insika
111
129
  # presentation tool joins on in a LATER turn. Absent until a card arrives.
112
130
  ev["cards"] = EvidenceLedger.merge_cards(ev["cards"], cards) unless Array(cards).empty?
113
131
  record["updated_at"] = timestamp
114
- @store.set(SCOPE, key_for(id), record)
132
+ write(record)
115
133
  to_session(record)
116
134
  end
117
135
 
@@ -138,7 +156,7 @@ module Insika
138
156
  value = Coercion.presence(Coercion.utf8(value.to_s))
139
157
  value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
140
158
  record["updated_at"] = timestamp
141
- @store.set(SCOPE, key_for(id), record)
159
+ write(record)
142
160
  to_session(record)
143
161
  end
144
162
 
@@ -149,7 +167,7 @@ module Insika
149
167
  briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
150
168
  briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
151
169
  record["updated_at"] = timestamp
152
- @store.set(SCOPE, key_for(id), record)
170
+ write(record)
153
171
  to_session(record)
154
172
  end
155
173
 
@@ -164,31 +182,130 @@ module Insika
164
182
  # read/mutate/set path suspends, so no other writer can interleave
165
183
  # mid-RMW. The LLM call that produced the summary happened BEFORE this
166
184
  # method; only the plain write lives here.
167
- def set_compaction(id, summary:, upto:, model: nil)
185
+ def set_compaction(id, summary: nil, upto:, model: nil, native: nil)
168
186
  record = fetch!(id)
169
187
  current = record["compaction"]
170
188
  upto = Integer(upto)
171
189
  return to_session(record) if current && upto <= current["upto"].to_i
172
190
 
173
191
  record["compaction"] = {
174
- "summary" => Coercion.utf8(summary.to_s),
192
+ "summary" => native ? nil : Coercion.utf8(summary.to_s),
193
+ "native" => native && Coercion.deep_stringify(native),
175
194
  "upto" => upto,
176
195
  "runs" => (current ? current["runs"].to_i : 0) + 1,
177
196
  "model" => Coercion.presence(model.to_s),
178
197
  "at" => timestamp
179
198
  }.compact
180
199
  record["updated_at"] = timestamp
181
- @store.set(SCOPE, key_for(id), record)
200
+ write(record)
201
+ to_session(record)
202
+ end
203
+
204
+ # -> Session. A person's verdict on one guardrail flag:
205
+ # messages[message]["guardrail_flags"][flag]["review"] = { status, at }.
206
+ # status "reported" (a false positive to fix in the corpus) or "resolved";
207
+ # nil clears it. updated_at is left alone — a review is not conversation
208
+ # activity. NotFoundError for an unknown session, message or flag.
209
+ REVIEW_STATUSES = %w[reported resolved].freeze
210
+
211
+ def review_guardrail_flag(id, message:, flag:, status:)
212
+ record = fetch!(id)
213
+ message = Integer(message)
214
+ flag = Integer(flag)
215
+ # a negative index would count from the end: a different flag
216
+ target = Array(record["messages"])[message] unless message.negative?
217
+ flags = target.is_a?(Hash) ? Array(target["guardrail_flags"]) : []
218
+ entry = flags[flag] unless flag.negative?
219
+ raise Insika::NotFoundError, "guardrail flag not found: #{id} #{message}/#{flag}" unless entry.is_a?(Hash)
220
+
221
+ if status.nil?
222
+ entry.delete("review")
223
+ else
224
+ raise Insika::ValidationError, "unknown review status: #{status}" unless REVIEW_STATUSES.include?(status.to_s)
225
+
226
+ entry["review"] = { "status" => status.to_s, "at" => timestamp }
227
+ end
228
+ write(record, amend: true)
182
229
  to_session(record)
183
230
  end
184
231
 
185
232
  # -> bool (delegates to the backend: false for a nonexistent id)
186
233
  def delete(id)
234
+ @store.delete(STATS_SCOPE, key_for(id))
187
235
  @store.delete(SCOPE, key_for(id))
188
236
  end
189
237
 
190
238
  # -> enumerates ids without the "session:" prefix. Without a block,
191
239
  # returns an Enumerator.
240
+ # -> [Session] every record, from ONE bulk read of the backend (see
241
+ # Store#entries) instead of a read per id.
242
+ def all
243
+ @store.entries(SCOPE, KEY_PREFIX).map { |_, record| to_session(record) }
244
+ end
245
+
246
+ # -> [Session] the `limit` most recently written sessions, newest first, from
247
+ # one read (Store#recent) instead of reading every session to sort them.
248
+ # `offset` skips that many of the newest (the next page).
249
+ def recent(limit, offset: 0)
250
+ # offset only when paging: a backend written for the 3-argument #recent keeps working
251
+ rows = offset.zero? ? @store.recent(SCOPE, KEY_PREFIX, limit) : @store.recent(SCOPE, KEY_PREFIX, limit, offset)
252
+ rows.map { |_, record| to_session(record) }
253
+ end
254
+
255
+ # -> Integer sessions stored, from the keys alone (no record is read).
256
+ def count = @store.list(SCOPE, KEY_PREFIX).size
257
+
258
+ # What a dashboard needs of a session without its messages: a few bytes per
259
+ # session, written with it, instead of the whole record (messages included).
260
+ # last_role: who spoke last ("user" = the customer is waiting); nil on stats
261
+ # written before it existed, until the session's next write.
262
+ # flagged: messages carrying a guardrail flag nobody resolved (a resolved
263
+ # flag stays on the message as history); reported: flags a person
264
+ # marked as a false positive and nobody resolved yet. Both 0 on stats
265
+ # written before they existed, until the session's next write.
266
+ # reported_at: the newest of those reports.
267
+ Stat = Data.define(:id, :updated_at, :message_count, :vars, :last_role, :flagged, :reported, :reported_at) do
268
+ def initialize(id:, updated_at:, message_count:, vars:, last_role: nil, flagged: 0, reported: 0, reported_at: nil) = super
269
+ end
270
+
271
+ # -> [Stat] every session's stats, in no particular order: small enough to read
272
+ # whole, so a reader never depends on the order they were written in.
273
+ def all_stats
274
+ @store.entries(STATS_SCOPE, KEY_PREFIX).map { |key, stat| to_stat(key, stat) }
275
+ end
276
+
277
+ # Bumped when stats gain a field (2: flagged/reported; 3: flagged skips
278
+ # resolved flags), so backfill_stats rewrites the older ones instead of
279
+ # only filling the missing.
280
+ STATS_VERSION = 3
281
+
282
+ # A guardrail flag still marks its conversation until a person resolves it.
283
+ def self.open_flag?(flag) = !(flag.is_a?(Hash) && flag.dig("review", "status") == "resolved")
284
+
285
+ # Writes the stats of every session that has none or older ones (sessions
286
+ # stored before a field existed) and deletes stats whose session is gone.
287
+ # Idempotent and safe with the app running: each batch re-reads inside a
288
+ # transaction, skipping a session that wrote its own stats meanwhile or was
289
+ # deleted. -> stats written.
290
+ def backfill_stats(batch: 500)
291
+ have = @store.entries(STATS_SCOPE, KEY_PREFIX).filter_map { |key, stat| key if current_stat?(stat) }.to_set
292
+ written = 0
293
+ @store.list(SCOPE, KEY_PREFIX).reject { |key| have.include?(key) }.each_slice(batch) do |keys|
294
+ @store.transaction do
295
+ keys.each do |key|
296
+ next if current_stat?(@store.get(STATS_SCOPE, key))
297
+
298
+ record = @store.get(SCOPE, key) or next
299
+ @store.set(STATS_SCOPE, key, stats_of(record))
300
+ written += 1
301
+ end
302
+ end
303
+ end
304
+ live = @store.list(SCOPE, KEY_PREFIX).to_set
305
+ @store.list(STATS_SCOPE, KEY_PREFIX).each { |key| @store.delete(STATS_SCOPE, key) unless live.include?(key) }
306
+ written
307
+ end
308
+
192
309
  def each_id
193
310
  return enum_for(:each_id) unless block_given?
194
311
 
@@ -199,6 +316,38 @@ module Insika
199
316
 
200
317
  private
201
318
 
319
+ # The one write of a session record: the record and its stats, side by side.
320
+ # amend: an edit that is not activity keeps the session's place in #recent.
321
+ def write(record, amend: false)
322
+ key = key_for(record["id"])
323
+ amend ? @store.amend(SCOPE, key, record) : @store.set(SCOPE, key, record)
324
+ @store.set(STATS_SCOPE, key, stats_of(record))
325
+ end
326
+
327
+ def current_stat?(stat) = stat.is_a?(Hash) && stat["v"].to_i >= STATS_VERSION
328
+
329
+ def stats_of(record)
330
+ messages = Array(record["messages"])
331
+ last = messages.last
332
+ vars = record["vars"] || {}
333
+ flags = messages.map { |m| m.is_a?(Hash) ? Array(m["guardrail_flags"]) : [] }
334
+ flagged = flags.count { |fs| fs.any? { self.class.open_flag?(_1) } }
335
+ reported = flags.flatten.select { |f| f.is_a?(Hash) && f.dig("review", "status") == "reported" }
336
+ { "v" => STATS_VERSION, "updated_at" => record["updated_at"], "message_count" => messages.size,
337
+ "agent" => vars["agent"], "customer" => vars["customer"],
338
+ "last_role" => last.is_a?(Hash) ? last["role"] : nil, "flagged" => (flagged if flagged.positive?),
339
+ "reported" => (reported.size if reported.any?), "reported_at" => reported.filter_map { |f| f.dig("review", "at") }.max,
340
+ **vars.slice(*CUSTOMER_LABELS) }.compact
341
+ end
342
+
343
+ def to_stat(key, stat)
344
+ Stat.new(id: key.delete_prefix(KEY_PREFIX), updated_at: stat["updated_at"],
345
+ message_count: stat["message_count"].to_i,
346
+ vars: stat.slice("agent", "customer", *CUSTOMER_LABELS),
347
+ last_role: stat["last_role"], flagged: stat["flagged"].to_i, reported: stat["reported"].to_i,
348
+ reported_at: stat["reported_at"])
349
+ end
350
+
202
351
  def key_for(id)
203
352
  "#{KEY_PREFIX}#{id}"
204
353
  end
@@ -36,7 +36,7 @@ module Insika
36
36
  # platform utility_model) into one history fragment. `prompt` replaces
37
37
  # the engine default wholesale (the distill convention). enabled: false
38
38
  # = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
39
- "compaction" => { "enabled" => false, "keep_last" => 20,
39
+ "compaction" => { "enabled" => false, "mode" => "summary", "keep_last" => 20,
40
40
  "compact_after" => 40, "model" => nil },
41
41
  # Fencing (per-agent `fencing` flag): the cap on ONE string leaf of a tool
42
42
  # result after sanitizing. Platform-wide — the leaf size is a context-budget
@@ -103,6 +103,23 @@ module Insika
103
103
  "agent_token_ceiling" => nil,
104
104
  "agent_token_window" => 86_400,
105
105
  "limit_response" => nil
106
+ },
107
+ # Burst policy — the platform layer of the QueuePolicy (session vars >
108
+ # agent limits > HERE > QueuePolicy::DEFAULTS). nil = no platform default:
109
+ # the key falls through to QueuePolicy::DEFAULTS (followup, no window,
110
+ # steer_max 5, raw text). Same keys the agent config form writes into
111
+ # profile.limits, so one deploy-wide answer no longer means editing every
112
+ # agent. Additive key — reads overlay DEFAULTS.
113
+ # Platform defaults for agent fields (Insika::AgentDefaults): an agent with
114
+ # no value of its own for one of AgentDefaults::FIELDS runs with this one.
115
+ # Written through #put_agent_defaults. Additive key.
116
+ "agent_defaults" => {},
117
+ "queue" => {
118
+ "queue_mode" => nil,
119
+ "debounce_ms" => nil,
120
+ "debounce_max_ms" => nil,
121
+ "steer_max_messages" => nil,
122
+ "steer_join" => nil
106
123
  }
107
124
  }.freeze
108
125
 
@@ -123,6 +140,35 @@ module Insika
123
140
  merged
124
141
  end
125
142
 
143
+ # Replaces the platform agent defaults per FIELD (AgentDefaults): a sent field
144
+ # replaces its whole value (unlike #update, which deep-merges and could never
145
+ # drop a key the form removed); nil drops it; fields not sent stay.
146
+ # -> Hash (the resulting agent_defaults).
147
+ def put_agent_defaults(patch)
148
+ data = get
149
+ defaults = (data["agent_defaults"] || {}).merge(stringify(patch || {})).compact
150
+ data["agent_defaults"] = defaults
151
+ @cs.put(SCOPE, KEY, data)
152
+ defaults
153
+ end
154
+
155
+ # Kits (Insika::Kits), replaced per kit: a sent kit replaces its whole value,
156
+ # nil deletes it, other kits stay. -> Hash (all kits).
157
+ def kits = get["kits"] || {}
158
+
159
+ def put_kit(name, kit)
160
+ data = get
161
+ all = (data["kits"] || {}).dup
162
+ if kit.nil?
163
+ all.delete(name.to_s)
164
+ else
165
+ all[name.to_s] = Insika::Kits.validate!(name, kit)
166
+ end
167
+ data["kits"] = all
168
+ @cs.put(SCOPE, KEY, data)
169
+ all
170
+ end
171
+
126
172
  # RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
127
173
  # report the current one). nil = no settings persisted yet (fresh deploy — nothing
128
174
  # to migrate); an Integer otherwise, 0 for a pre-versioning record.