insika 0.1.0 → 0.3.0

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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -52,12 +52,12 @@ module Insika
52
52
  Sync { do_recovery }
53
53
  end
54
54
 
55
- # Task recovery THEN delegation recovery (RFC-0010 Fase 2): the delegation
55
+ # Task recovery THEN delegation recovery: the delegation
56
56
  # sweep re-delivers completed-but-undelivered async delegations, and depends
57
57
  # on the task sweep having re-dispatched any in-flight children first. Both
58
58
  # create task fibers, so both must run inside the reactor scope of run_recovery.
59
59
  #
60
- # The TASK sweep is additionally gated per boot generation (RFC-0016 E2):
60
+ # The TASK sweep is additionally gated per boot generation:
61
61
  # its "orphaned :running" test cannot see a sibling worker's live fiber, so
62
62
  # only the worker that claims the generation sweeps — the others would steal
63
63
  # in-flight turns. The delegation and channel sweeps stay ungated: each of
@@ -89,7 +89,7 @@ module Insika
89
89
  log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
90
90
  end
91
91
 
92
- # RFC-0011 §6.5: replies a previous process committed but never handed to the
92
+ # replies a previous process committed but never handed to the
93
93
  # channel. Runs AFTER the task recovery for the same reason the delegation
94
94
  # sweep does — a resumed turn writes its own outbox record at its terminal, and
95
95
  # sweeping first would miss it.
@@ -108,7 +108,7 @@ module Insika
108
108
  return if @wiring.durable?
109
109
 
110
110
  log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
111
- "not resume anything after a restart (doc 02 §6).")
111
+ "not resume anything after a restart.")
112
112
  end
113
113
 
114
114
  def log(message)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RFC-0017 A3 — the /v1 transport as a VALUE the host app mounts, instead of a
3
+ # the /v1 transport as a VALUE the host app mounts, instead of a
4
4
  # server the engine starts.
5
5
  #
6
6
  # mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
@@ -11,7 +11,7 @@
11
11
  # server boot now calls this instead of inlining it, which is what keeps the two
12
12
  # from drifting.
13
13
  #
14
- # The Studio is deliberately NOT part of this (the embed contract, item 4): it is
14
+ # The Studio is deliberately NOT part of this (the embed contract): it is
15
15
  # a class-level singleton, so it is one per process, and a host that wants the
16
16
  # operator UI mounts `Studio::App` itself and accepts that limitation.
17
17
 
@@ -42,29 +42,42 @@ module Insika
42
42
  end
43
43
 
44
44
  attr_reader :token
45
+ attr_reader :graph
46
+ # the frozen criterion, memoized when the relay runs in shadow —
47
+ # nil otherwise. Loaded ONCE at boot (the refusal below), then shared.
48
+ attr_reader :criterion
45
49
 
46
50
  def app
51
+ tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
52
+ # WS1: the token store is handed over ONLY in multi_tenant mode — in
53
+ # single_tenant the classic gateway token is the only credential
54
+ # (passing the store would silently widen the surface).
55
+ store = tenancy == "multi_tenant" ? @graph.token_store : nil
47
56
  @app ||= Insika::Server::App.new(
48
57
  command_bus: @graph.bus, event_stream: @graph.event_stream,
49
58
  session_store: @graph.session_store, task_store: @graph.task_store,
50
59
  pending_action_store: @graph.pending_action_store,
51
60
  provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
52
61
  # GET /v1/agents/:id — the read-only capability view a case's `requires`
53
- # resolves against (RFC-0014 §3.2).
62
+ # resolves against.
54
63
  profiles: @graph.profiles,
55
- # Item 20 / §5.6: the OSS onboarding surface (start.md + models.json + docs).
64
+ # the OSS onboarding surface (start.md + models.json + docs).
56
65
  # This is the primary "build my first agent" target — models.json reports the
57
66
  # DSL's stores + the agents this process serves (each id IS the `model`).
58
67
  onboarding: build_onboarding,
59
- # Item 22: GET /v1/workflows + POST /v1/workflows/:name, opt-in by
68
+ # GET /v1/workflows + POST /v1/workflows/:name, opt-in by
60
69
  # injection like every other edge — nil when the system declares none,
61
70
  # so the routes simply do not exist (404, parity).
62
71
  workflow_registry: (@graph.workflow_registry if workflows?),
63
- # RFC-0011: the bundled relay, when the env turns it on. Same rule as the
72
+ # the bundled relay, when the env turns it on. Same rule as the
64
73
  # OTEL bridge — a feature only `config.ru` can reach is a feature the
65
74
  # docs are half-true about.
66
75
  channels: (@graph.channel_registry if channels?),
67
- config: { gateway_token: @token }.merge(@config)
76
+ config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
77
+ token_store: store,
78
+ outcome_store: @graph.outcome_store,
79
+ # a 500's error_ref must be findable in the process log.
80
+ logger: $stdout
68
81
  )
69
82
  end
70
83
 
@@ -79,6 +92,17 @@ module Insika
79
92
  allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
80
93
  allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
81
94
  )
95
+ # NO criterion, no shadow — enforced at boot, not per
96
+ # turn. Raises ConfigError when the file is missing, unparseable, or
97
+ # incomplete; the criterion is memoized here and injected into
98
+ # ChannelDelivery (its sha stamps every pair our half records).
99
+ if relay&.shadow?
100
+ criterion_path = Insika::EnvSchema.read("INSIKA_PARITY_CRITERION")
101
+ raise Insika::ConfigError, "shadow mode requires INSIKA_PARITY_CRITERION (the frozen criterion)" if criterion_path.nil?
102
+
103
+ @criterion = Insika::Parity::Criterion.load(criterion_path)
104
+ @graph.channel_delivery&.criterion_sha = @criterion.sha
105
+ end
82
106
  @graph.channel_registry.register(relay.id, relay) if relay
83
107
 
84
108
  widget = Insika::Channels::Web.from_env(
@@ -4,9 +4,8 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Server
7
- # OpenAI Responses edge adapter (`/v1/responses`) — the contract that the
8
- # OpenClaw gateway consumers already speak (see achei-b2b
9
- # `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
7
+ # OpenAI Responses edge adapter (`/v1/responses`) — the contract that
8
+ # OpenClaw gateway consumers already speak.
10
9
  #
11
10
  # PURE module (no state, no framework): (a) translates the OpenAI
12
11
  # Responses request → `:send_message` payload; (b) maps each turn Event →
@@ -26,7 +25,7 @@ module Insika
26
25
  # `origin` is the consumer declaring WHO wrote the input it is sending. It
27
26
  # matters here more than anywhere: this adapter's `input` is a STRING the
28
27
  # consumer already composed out of context blocks plus the customer's text
29
- # (`<memoria> …`, `<cacau_cep_obrigatorio> …`), so a transcript reader cannot
28
+ # (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
30
29
  # tell the two apart — the first refinement run over real traffic reported 219
31
30
  # "the customer repeated themselves" that were the engine reading its own
32
31
  # fragment back. A consumer that sends `origin: "engine"` on a composed turn
@@ -41,10 +40,45 @@ module Insika
41
40
  raise Insika::ValidationError, "user missing" if user.strip.empty?
42
41
 
43
42
  message = extract_input(body[:input])
44
- raise Insika::ValidationError, "input empty" if message.strip.empty?
45
43
 
46
44
  out = { agent: agent.strip, user: user, message: message }
47
45
  (origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
46
+ # WS8: the optional customer_key — per-customer memory scope + purge handle
47
+ (customer = Insika::Coercion.presence(body[:customer])) && (out[:customer] = customer)
48
+ # WS9: the multimodal OPENAI shape — `input` as an array of content parts
49
+ # ({type: text/image/audio}) is preserved additively alongside the
50
+ # joined text; a string input stays byte-identical to before. The
51
+ # CONTRACT is enforced here (422) — the engine stays lenient.
52
+ raw = body[:input]
53
+ if raw.is_a?(Array)
54
+ unless Insika::Media.well_formed?(raw)
55
+ raise Insika::ValidationError,
56
+ "malformed content part — each part must be {type: text|image|audio} with text/url"
57
+ end
58
+
59
+ normalized = Insika::Media.parts(raw).map do |p|
60
+ { "type" => p.type, "text" => p.text, "url" => p.url }.compact
61
+ end
62
+ out[:parts] = normalized unless normalized.empty?
63
+ end
64
+ # The turn needs SOMETHING to be about — text, or media the engine will
65
+ # turn into text (a voice note) or show the model (a photo). Checked
66
+ # after the parts are known, because the anchor use case (a WhatsApp
67
+ # voice note, no caption) carries no text at all and joining only the
68
+ # text parts made it a 422 at the door.
69
+ if message.strip.empty? && Array(out[:parts]).none? { |p| p["type"] != "text" }
70
+ raise Insika::ValidationError, "input empty"
71
+ end
72
+ # WS9: `source` marks pre-transcribed voice text; anything else is refused.
73
+ unless body[:source].nil? || body[:source].to_s == "voice"
74
+ raise Insika::ValidationError, 'source must be "voice"'
75
+ end
76
+ out[:source] = body[:source].to_s if Insika::Coercion.presence(body[:source])
77
+ # WS9 (saída): the channel declares which generated media it can
78
+ # RECEIVE ({ capabilities: ["image_output", "audio_output"] }). Additive
79
+ # + additive sibling on the completed frame; the exact capabilities are
80
+ # validated at the boundary (message_flow), not here.
81
+ (channel = body[:channel]) && (out[:channel] = channel)
48
82
  out
49
83
  end
50
84
 
@@ -84,8 +118,8 @@ module Insika
84
118
  # The provider's reasoning. Internal unless the AGENT opted in
85
119
  # (`edge_stream thinking: true`), which tags the event. Even then it does
86
120
  # NOT become answer text: it gets the Responses reasoning frame, so a
87
- # consumer that only accumulates `output_text` deltas — achei-b2b's
88
- # dispatcher, which turns them into one WhatsApp message — is unaffected,
121
+ # consumer that only accumulates `output_text` deltas — a dispatcher
122
+ # that turns them into one WhatsApp message — is unaffected,
89
123
  # and one that renders reasoning has something to render.
90
124
  if public_delta(event)
91
125
  sse("response.reasoning_summary_text.delta",
@@ -108,13 +142,19 @@ module Insika
108
142
  { type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
109
143
  end
110
144
  when :guardrail_blocked, :guardrail_flagged
111
- # RFC-0009: audit events with no OpenAI Responses counterpart. On a BLOCK
145
+ # audit events with no OpenAI Responses counterpart. On a BLOCK
112
146
  # the safe reply still reaches the consumer through the normal :content
113
147
  # deltas + :task_completed path (the turn completes gracefully), so there
114
148
  # is nothing extra to translate here — the events live in /v1/events + the
115
149
  # Studio + the trace. Explicit (not a fall-through) to keep the closed
116
150
  # catalog honest.
117
151
  nil
152
+ when :ttft
153
+ # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
154
+ # ms-to-first-token, emitted when the first content chunk arrives.
155
+ # Namespaced insika.* — no OpenAI Responses counterpart; unknown types
156
+ # are ignored, the safe failure.
157
+ sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
118
158
  end
119
159
  end
120
160
 
@@ -133,9 +173,18 @@ module Insika
133
173
  response[:usage] = usage.reject { |k, _| k.to_s == "model" }
134
174
  response[:model] = model if model
135
175
  end
136
- # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING; item 34). Absent
176
+ # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
137
177
  # by default — a non-standard sibling used only for TTFB diagnostics.
138
178
  (timing = event.data[:timing]) && (response[:timing] = timing)
179
+ # WS5 stuck signal: an additive sibling the terminal frame carries when the
180
+ # agent ended the turn declaring it cannot proceed. Consumers that only read
181
+ # the OpenAI-shaped response.use it to run their escalation ("stuck" means
182
+ # what they decide it means, never the engine's business).
183
+ (outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
184
+ # WS9 (saída): generated media parts (image/audio clips) ride the
185
+ # completed frame additively next to the text — absent when none were
186
+ # generated. The base64 bytes are the consumer's to render/upload.
187
+ (parts = event.data[:output_parts]) && (response[:output_parts] = parts)
139
188
  sse("response.completed", { type: "response.completed", response: response })
140
189
  end
141
190
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/utils"
5
+
6
+ module Insika
7
+ module Server
8
+ # Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
9
+ # a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
10
+ # one gate:
11
+ #
12
+ # single_tenant (default) — no token store: the classic single operator
13
+ # credential (config[:gateway_token]) is the only thing that resolves.
14
+ # multi_tenant — tokens live in the TokenStore (per-tenant + operator).
15
+ # A configured gateway_token STILL resolves as operator (an existing
16
+ # deployment switching modes keeps its credential — additive, never
17
+ # a second-class path).
18
+ #
19
+ # Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
20
+ # (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
21
+ # testable without a Rack env.
22
+ module TenantAuth
23
+ module_function
24
+
25
+ # gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
26
+ # nil. header: raw Authorization value.
27
+ # -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
28
+ def check(gateway_token, token_store, header)
29
+ # Fail-closed FIRST (the construction rule): with no store AND no
30
+ # configured token the gateway is DISABLED (503) however the request
31
+ # looks — never "401: who are you facing a door that does not exist".
32
+ # A token_store present means the gateway IS configured (multi_tenant),
33
+ # with or without the legacy gateway token.
34
+ return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
35
+
36
+ provided = header.to_s[/\ABearer (.+)\z/, 1]
37
+ return :unauthorized if provided.nil?
38
+
39
+ if token_store
40
+ record = token_store.resolve(provided)
41
+ unless record
42
+ # store miss -> the legacy gateway token still resolves as operator
43
+ # (an existing deployment switching modes keeps its credential).
44
+ return :unauthorized if gateway_token.nil? || gateway_token.empty?
45
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
46
+
47
+ return { role: "operator", tenant_id: nil }
48
+ end
49
+
50
+ return { role: record.role.to_s, tenant_id: record.tenant_id }
51
+ end
52
+
53
+ # classic mode (no store): the gateway token is the only credential.
54
+ # Constant-time comparison: the operator token doesn't leak via timing.
55
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
56
+
57
+ { role: "operator", tenant_id: nil }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -17,7 +17,7 @@ module Insika
17
17
  # Executor) is also born on the supervisor; the SessionActor only AWAITS it to
18
18
  # serialize.
19
19
  #
20
- # RFC-0015: it is also where an inbound message for a BUSY session is routed.
20
+ # it is also where an inbound message for a BUSY session is routed.
21
21
  # That decision belongs here and nowhere else — this is already the object that
22
22
  # owns "one turn at a time for this session". Putting it in the HTTP handler
23
23
  # would duplicate the invariant; putting it in the Executor would mix turn
@@ -40,12 +40,16 @@ module Insika
40
40
  #
41
41
  # `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
42
42
  # policy without a window behaves exactly as before — dequeued and run at once.
43
- def enqueue(task, profile:, resume_from: nil, policy: nil)
44
- @queue.enqueue([task, profile, resume_from, policy])
43
+ #
44
+ # `timing` is the channel clock a channel turn allocated at 202
45
+ # acceptance and already stamped `:inbound`; it rides the queue so the debounce
46
+ # window and the FIFO wait land INSIDE first_balloon_ms.
47
+ def enqueue(task, profile:, resume_from: nil, policy: nil, timing: nil)
48
+ @queue.enqueue([task, profile, resume_from, policy, timing])
45
49
  task.id
46
50
  end
47
51
 
48
- # RFC-0015 §5.3 — merge a fragment into the turn waiting at the door.
52
+ # merge a fragment into the turn waiting at the door.
49
53
  # -> the task id it joined, or nil when there is nothing to merge into (no
50
54
  # pending turn, the window has closed, or the turn already started). nil is
51
55
  # the caller's signal to create a task of its own.
@@ -99,12 +103,12 @@ module Insika
99
103
 
100
104
  def run_loop
101
105
  loop do
102
- task, profile, resume_from, policy = @queue.dequeue # blocks when empty
106
+ task, profile, resume_from, policy, timing = @queue.dequeue # blocks when empty
103
107
  task = hold_at_the_door(task, policy)
104
108
  @running = true
105
109
  @current_task = task
106
110
  begin
107
- @executor.run_serial(task, profile: profile, resume_from: resume_from)
111
+ @executor.run_serial(task, profile: profile, resume_from: resume_from, timing: timing)
108
112
  rescue StandardError
109
113
  # run_serial already maps turn errors; this rescue is defense: an
110
114
  # unexpected error must NEVER bring down the session loop (Async::Stop <
@@ -117,7 +121,7 @@ module Insika
117
121
  end
118
122
  end
119
123
 
120
- # RFC-0015 §5.3 — the debounce window. Sleeps on the LOOP's fiber, never on the
124
+ # the debounce window. Sleeps on the LOOP's fiber, never on the
121
125
  # request's, so the POST is acked immediately and the platform does not retry.
122
126
  # Returns the task to run (re-read from the store when fragments merged into it,
123
127
  # since the in-memory Task is a frozen snapshot of an older message).
@@ -23,7 +23,14 @@ module Insika
23
23
  KEY_PREFIX = "session:"
24
24
 
25
25
  Session = Data.define(:id, :messages, :vars, :memory_refs,
26
- :created_at, :updated_at)
26
+ :created_at, :updated_at, :briefing, :evidence) do
27
+ # Trailing members with defaults: an old record without the "briefing" /
28
+ # "evidence" keys reads as empty/nil without a migration.
29
+ def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
30
+ briefing: nil, evidence: nil)
31
+ super
32
+ end
33
+ end
27
34
 
28
35
  # store: any Insika::Store (Memory, SQLite, ...) — injected by the
29
36
  # composition root (config/wiring.rb). SessionStore does not know the
@@ -44,6 +51,8 @@ module Insika
44
51
  "messages" => [],
45
52
  "vars" => deep_stringify(vars),
46
53
  "memory_refs" => [],
54
+ "briefing" => { "fields" => {}, "next_step" => nil },
55
+ "evidence" => { "ids" => [], "ungrounded" => 0 },
47
56
  "created_at" => now,
48
57
  "updated_at" => now
49
58
  }
@@ -61,7 +70,7 @@ module Insika
61
70
  # fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
62
71
  # provided. NotFoundError if the session does not exist.
63
72
  #
64
- # CONCURRENCY LIMITATION (§11 R2c): the RMW (read record -> += -> set) is
73
+ # CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is
65
74
  # atomic ONLY because the SessionActor serializes turns of the same session
66
75
  # (one owner at a time). That serialization exists solely in SUPERVISED mode
67
76
  # (the actor loop lives on the supervisor). Two concurrent send_message on the
@@ -89,6 +98,58 @@ module Insika
89
98
  to_session(record)
90
99
  end
91
100
 
101
+ # appends this turn's evidence (ids + ungrounded delta) to the
102
+ # session record. RMW like append_messages — the SessionActor serializes
103
+ # same-session turns; the copy is in the method comment.
104
+ def append_evidence(id, ids:, ungrounded:)
105
+ record = fetch!(id)
106
+ ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
107
+ fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
108
+ ev["ids"] = fresh
109
+ ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
110
+ record["updated_at"] = timestamp
111
+ @store.set(SCOPE, key_for(id), record)
112
+ to_session(record)
113
+ end
114
+
115
+ # -> Session. Upsert ONE briefing field. The pack owns the schema; the
116
+ # engine validates nothing about field NAMES here (the tools do, at the
117
+ # write edge). value is a String (anything else -> to_s); a BLANK value
118
+ # (after strip) REMOVES the key — absence means "not yet asked" (
119
+ # D4). NotFoundError if the session does not exist.
120
+ #
121
+ # CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
122
+ # append_messages — but the SessionActor argument does NOT apply here. A
123
+ # briefing writer is a system tool, never enveloped, and with
124
+ # tool_concurrency > 1 the gem runs each call in its OWN fiber — outside the
125
+ # actor's per-session turn serialization. The RMW is still safe, for a
126
+ # different reason: nothing in the read/mutate/set path suspends. Store
127
+ # get/set are synchronous (the SQLite write semaphore is a non-yielding fast
128
+ # path when free), and a fiber only switches at a scheduler suspension point
129
+ # — so no other writer can interleave mid-RMW (measured: N concurrent
130
+ # writers lose nothing). It holds ONLY while that path never suspends; an
131
+ # async store (a real yield in get/set) would need a lock or CAS.
132
+ def update_briefing(id, field:, value:)
133
+ record = fetch!(id)
134
+ briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
135
+ value = Coercion.presence(Coercion.utf8(value.to_s))
136
+ value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
137
+ record["updated_at"] = timestamp
138
+ @store.set(SCOPE, key_for(id), record)
139
+ to_session(record)
140
+ end
141
+
142
+ # -> Session. Upsert the agreed next step; a blank text clears to nil
143
+ # NotFoundError if absent.
144
+ def set_next_step(id, text:)
145
+ record = fetch!(id)
146
+ briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
147
+ briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
148
+ record["updated_at"] = timestamp
149
+ @store.set(SCOPE, key_for(id), record)
150
+ to_session(record)
151
+ end
152
+
92
153
  # -> bool (delegates to the backend: false for a nonexistent id)
93
154
  def delete(id)
94
155
  @store.delete(SCOPE, key_for(id))
@@ -126,7 +187,9 @@ module Insika
126
187
  vars: record["vars"],
127
188
  memory_refs: record["memory_refs"],
128
189
  created_at: record["created_at"],
129
- updated_at: record["updated_at"]
190
+ updated_at: record["updated_at"],
191
+ briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
192
+ evidence: record["evidence"]
130
193
  )
131
194
  end
132
195
 
@@ -13,7 +13,7 @@ module Insika
13
13
  SCOPE = "settings"
14
14
  KEY = "general"
15
15
 
16
- # STRICT config, settings layer (item 23 / §8.1 — "no silent config compat: every
16
+ # STRICT config, settings layer (— "no silent config compat: every
17
17
  # schema migration explicit"). The settings record carries a `schema_version`;
18
18
  # every shape change is a numbered migration here, applied ONLY by the explicit
19
19
  # `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
@@ -31,7 +31,17 @@ module Insika
31
31
  "turn_timeout" => 120,
32
32
  "tool_timeout" => 30,
33
33
  "compaction" => { "enabled" => false, "keep_last" => 20 },
34
- # LLM config v2 (§10). Platform-wide model layer, resolved by the
34
+ # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
35
+ # tick's Retention sweep purges sessions (+traces), terminal tasks
36
+ # (+checkpoints), memory cells and outcomes older than this. nil/0 =
37
+ # OFF (parity — nothing is ever swept by default).
38
+ "retention_days" => nil,
39
+ # memory TTL. Integer = default for every cell; Hash =
40
+ # per-tenant { "<tenant>" => days, "*" => days } (ops-authored). nil = OFF.
41
+ # Swept by the Retention tick on its OWN daily claim, NOT gated by
42
+ # retention_days (D5). Additive key — reads overlay DEFAULTS.
43
+ "memory_ttl_days" => nil,
44
+ # LLM config v2. Platform-wide model layer, resolved by the
35
45
  # ModelResolver under an agent that pins no model of its own:
36
46
  # default_model/default_provider -> the platform default (Chat > Agent > HERE)
37
47
  # fallback_models -> ordered chain ["provider/model" | "model", ...] tried
@@ -42,13 +52,13 @@ module Insika
42
52
  "default_provider" => nil,
43
53
  "fallback_models" => [],
44
54
  "utility_model" => nil,
45
- # Reasoning control (§10, 4-layer: Chat > Agent > Model > Global). `thinking`
55
+ # Reasoning control (4-layer: Chat > Agent > Model > Global). `thinking`
46
56
  # is the GLOBAL default (off/on/low/medium/high; nil = provider default);
47
57
  # `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
48
58
  # { "thinking" => ... }. Both resolved by the ModelResolver.
49
59
  "thinking" => nil,
50
60
  "model_params" => {},
51
- # Evals (RFC-0008, panel by RFC-0013 §3.9). The GRADERS are platform config, so
61
+ # Evals (panel by). The GRADERS are platform config, so
52
62
  # the operator picks them in the Studio instead of remembering a CLI flag:
53
63
  # judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
54
64
  # asserts only (rubric'd cases read as judge_pending).
@@ -66,7 +76,7 @@ module Insika
66
76
  "quorum" => 1,
67
77
  "tolerance" => 0.05
68
78
  },
69
- # Edge limits (item 33 / §12 G7) — the platform layer of the EdgeLimiter.
79
+ # Edge limits — the platform layer of the EdgeLimiter.
70
80
  # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
71
81
  # chat_rate_window (s); agent_token_ceiling = total tokens per agent per
72
82
  # agent_token_window (s). limit_response overrides the safe reply.