insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Fixed-window spend counters for BUDGETS (WS2): the accounting layer the
5
+ # edge middleware rolls against. One scope, cells keyed
6
+ # "tenant:agent:window:calendar-bucket":
7
+ #
8
+ # · DAILY — the UTC calendar day (epoch/86400 IS midnight-aligned).
9
+ # · MONTHLY — (year * 12 + month) of the UTC calendar month: a budget month
10
+ # is the CALENDAR month, however many days long it is (a fixed
11
+ # N-day window drifts its bucket start across month lengths).
12
+ # UTC like the daily, so both rollovers agree on any host.
13
+ #
14
+ # Built on the UsageLedger vocabulary (tenant/agent instead of kind/id) but
15
+ # on the store directly, with the increment riding `@store.transaction` — the
16
+ # exact read-modify-write discipline WS2's enforcement will build on. Two
17
+ # processes (or two SQLite handles) racing the same cell serialize on
18
+ # BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
19
+ # WS2; this file is only correct accounting.
20
+ #
21
+ # Growth is bounded like UsageLedger: each `add` deletes the (id)'s previous
22
+ # day AND previous month cell, so an active scope holds at most 4 keys and an
23
+ # idle one converges to 2.
24
+ class BudgetLedger
25
+ SCOPE = "budget_counters"
26
+ ALERT_SCOPE = "budget_alerts"
27
+ DAY = 86_400
28
+
29
+ def initialize(store:)
30
+ @store = store
31
+ end
32
+
33
+ # Adds `by` across both windows; -> { daily:, monthly: } the NEW totals
34
+ # for (tenant, agent). Atomic per call: one transaction, both bumps.
35
+ def add(tenant:, agent:, by:, now: Time.now)
36
+ id = cell_id(tenant, agent)
37
+ @store.transaction do
38
+ daily = bump(id, daily_bucket(now), by)
39
+ monthly = bump(id, month_bucket(now), by)
40
+ @store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell
41
+ @store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell
42
+ { daily: daily, monthly: monthly }
43
+ end
44
+ end
45
+
46
+ # -> { daily:, monthly: } current totals for (tenant, agent). Purely
47
+ # read; an expired window reads as 0 (rolls over at the boundary).
48
+ def current(tenant:, agent:, now: Time.now)
49
+ id = cell_id(tenant, agent)
50
+ { daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i,
51
+ monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i }
52
+ end
53
+
54
+ # Seconds until the window's bucket rolls over (the retry_after the
55
+ # enforcement quotes when a hard budget refuses a turn). Both windows are
56
+ # UTC-aligned (the daily via the epoch, the monthly via UTC components) so a
57
+ # non-UTC host never quotes a negative or local-midnight reset.
58
+ def reset_in(window, now: Time.now)
59
+ case window
60
+ when :daily then DAY - (now.to_i % DAY)
61
+ when :monthly then (next_utc_month_start(now) - now).to_i
62
+ end
63
+ end
64
+
65
+ # "1× per window" alert markers (the soft enforcement's event): a flag per
66
+ # (id, window, level, bucket) so a budget that stays over the threshold
67
+ # cannot spam one event per turn. `level:` separates DISTINCT triggers in
68
+ # the same window (WS2): the `alert_at` crossing and the real soft-cap
69
+ # crossing each warn once — the cap event must not be swallowed by the
70
+ # 80% marker having fired earlier. Marked/read in the same transaction
71
+ # discipline. -> bool: had the window already been marked?
72
+ def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now)
73
+ id = cell_id(tenant, agent)
74
+ flag = alert_key(id, window, now, level)
75
+ @store.transaction do
76
+ # `next`, NOT `return`: a non-local return from inside the block skips
77
+ # the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn
78
+ # over a threshold then locks the whole backend (WS2).
79
+ next true unless @store.get(ALERT_SCOPE, flag).nil?
80
+
81
+ @store.set(ALERT_SCOPE, flag, 1)
82
+ false
83
+ end
84
+ end
85
+
86
+ def alerted?(tenant:, agent:, window:, level: nil, now: Time.now)
87
+ !@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
88
+ end
89
+
90
+ private
91
+
92
+ # No tenant (single_tenant default) is a LITERAL "platform" cell, never a
93
+ # null-key collision with some other scope.
94
+ def cell_id(tenant, agent)
95
+ [tenant || "platform", agent].join(":")
96
+ end
97
+
98
+ def bump(id, bucket, by)
99
+ total = @store.get(SCOPE, key(id, bucket)).to_i + by
100
+ @store.set(SCOPE, key(id, bucket), total)
101
+ total
102
+ end
103
+
104
+ # the UTC calendar day's start (epoch is aligned to midnight UTC).
105
+ def daily_bucket(now)
106
+ (now.to_i / DAY) * DAY
107
+ end
108
+
109
+ # the UTC calendar month as one integer (2026-08 -> 24296). UTC, not local:
110
+ # the month boundary must agree with the daily epoch-day boundary on a
111
+ # non-UTC host (WS2), or the cap resets at a different moment than the day.
112
+ def month_bucket(now)
113
+ u = now.utc
114
+ u.year * 12 + u.month
115
+ end
116
+
117
+ # Midnight UTC of the 1st of the window's NEXT month — December-safe
118
+ # (Time.utc(y, 13, 1) raises; y+1/1 is the calendar answer).
119
+ def next_utc_month_start(now)
120
+ u = now.utc
121
+ u.month == 12 ? Time.utc(u.year + 1, 1, 1) : Time.utc(u.year, u.month + 1, 1)
122
+ end
123
+
124
+ def key(id, bucket)
125
+ "#{id}:#{bucket}"
126
+ end
127
+
128
+ # One alert flag per (id, window, level, calendar bucket): daily cells are
129
+ # keyed by day, monthly by (year*12+month) — a flag dies with its window.
130
+ def alert_key(id, window, now, level = nil)
131
+ bucket = window == :monthly ? month_bucket(now) : daily_bucket(now)
132
+ level ? "#{id}:#{window}:#{level}:#{bucket}" : "#{id}:#{window}:#{bucket}"
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Insika
6
+ module Capability
7
+ # Thin decorator: swaps only the `name` exposed to the model for the
8
+ # STABLE capability name (e.g. "browse"), regardless of which concrete impl
9
+ # (`impl_name`, e.g. "puppeteer_browser") resolution chose.
10
+ # `execute`/`parameters`/`description`/`call` keep delegating to the impl via
11
+ # SimpleDelegator — nothing reimplemented, same spirit as `ToolEnvelope`.
12
+ #
13
+ # Wrapping order in run_pipeline: impl -> ResolvedTool ->
14
+ # ToolEnvelope. The Envelope, from the outside, sees the already-renamed call (the
15
+ # model calls `browse`); for side_effect?/approval it needs the REAL `impl_name`
16
+ # (it's the tool_registry that knows whether "puppeteer_browser" is a side-effect, not
17
+ # "browse") — that's why `impl_name` is exposed here. This is consumed by the
18
+ # Executor (ToolEnvelope).
19
+ class ResolvedTool < SimpleDelegator
20
+ def initialize(impl, capability_name:, impl_name:)
21
+ super(impl)
22
+ @capability_name = capability_name.to_s
23
+ @impl_name = impl_name.to_s
24
+ end
25
+
26
+ # STABLE name exposed to the model — shadows the impl's `name`.
27
+ def name = @capability_name
28
+
29
+ # Concrete name behind resolution — for side_effect?/approval in the
30
+ # ToolEnvelope, NEVER exposed to the model.
31
+ def impl_name = @impl_name
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Intent→implementation resolution. Pure INDIRECTION: does NOT inherit
5
+ # from `Registry` (which holds executables) — it holds `Provider`s (resolution
6
+ # metadata) and returns the `impl_name` that ANOTHER registry instantiates.
7
+ # Immutable post-boot by construction (only boot/loader registers), like `Registry`.
8
+ class CapabilityRegistry
9
+ Provider = Data.define(:capability, :impl_name, :kind, :plugin, :priority, :available)
10
+ # kind: :tool | :workflow
11
+ # priority: Integer | nil (nil = lowest; inherits plugin precedence)
12
+ # available: callable -> bool (default -> { true }; never nil in a registered Provider)
13
+
14
+ def initialize
15
+ # capability(Symbol) -> [Provider], na ordem de registro (proxy de announce)
16
+ @providers = Hash.new { |h, k| h[k] = [] }
17
+ end
18
+
19
+ # Unlike `Registry#register`, there is NO "first wins": registering the
20
+ # same capability more than once is the normal case (providers competing) —
21
+ # dedup/tie-breaking happens in `resolve`, not here.
22
+ def register(capability, impl_name:, kind:, plugin: nil, priority: nil, available: nil)
23
+ unless %i[tool workflow].include?(kind)
24
+ raise ArgumentError, "invalid kind: #{kind.inspect} (use :tool or :workflow)"
25
+ end
26
+
27
+ if kind == :workflow
28
+ warn "[capability_registry] '#{capability}' registered with kind: :workflow — " \
29
+ "exposure to the agent deferred (L5)"
30
+ end
31
+
32
+ @providers[capability.to_sym] << Provider.new(
33
+ capability: capability.to_sym, impl_name: impl_name.to_s, kind: kind,
34
+ plugin: plugin&.to_s, priority: priority, available: available || -> { true }
35
+ )
36
+ self
37
+ end
38
+
39
+ def providers(capability) = @providers[capability.to_sym].dup
40
+
41
+ def capabilities = @providers.keys
42
+
43
+ # Loader rollback, symmetric to `Registry#deregister_plugin`. Removes
44
+ # only the plugin's Providers; capabilities with no remaining provider drop
45
+ # from `capabilities` (clears the key so the Hash.new-with-block won't recreate it empty).
46
+ def deregister_plugin(plugin_id)
47
+ @providers.each_value { |list| list.delete_if { |p| p.plugin == plugin_id.to_s } }
48
+ @providers.delete_if { |_cap, list| list.empty? }
49
+ nil
50
+ end
51
+
52
+ # -> chosen Provider | raise CapabilityUnavailable | raise CapabilityAmbiguous.
53
+ # PURE and deterministic: same input → same choice or same error. No
54
+ # IO beyond the Provider's own `available.call`. Emits `:capability_resolved`
55
+ # when `event_stream:` is present (audit).
56
+ def resolve(capability, profile:, context: {}, event_stream: nil)
57
+ candidates = providers(capability)
58
+ candidates = candidates.select { |p| p.available.call }
59
+ candidates = apply_deny(candidates, profile)
60
+
61
+ raise CapabilityUnavailable.new(capability: capability) if candidates.empty?
62
+
63
+ chosen = pick_top(candidates, capability)
64
+ event_stream&.emit(Insika::Event.new(
65
+ type: :capability_resolved,
66
+ data: {
67
+ capability: capability.to_sym,
68
+ chosen: chosen.impl_name,
69
+ candidates: candidates.map do |p|
70
+ { impl_name: p.impl_name, plugin: p.plugin, priority: p.priority }
71
+ end
72
+ }
73
+ ))
74
+ chosen
75
+ end
76
+
77
+ private
78
+
79
+ # Resolution applies ONLY `tools_deny` over `impl_name` (deny ALWAYS wins) — it does
80
+ # NOT apply `tools_allow`: the grant to use the capability is
81
+ # listing it in `profile.capabilities`, checked by the Executor BEFORE
82
+ # calling `resolve`. Reusing `tools_allow` would filter out a provider for
83
+ # an agent that lists only the capability (not the raw impl). Per-agent provider
84
+ # pinning (`capability_providers`) is future work.
85
+ def apply_deny(candidates, profile)
86
+ deny = Array(profile.tools_deny).map(&:to_s)
87
+ candidates.reject { |p| deny.include?(p.impl_name) }
88
+ end
89
+
90
+ # `priority` desc primary, `nil` as the LOWEST possible (below
91
+ # any Integer, including negative — do not normalize to 0, which would collide
92
+ # with an explicit `priority: 0`). Tie-break by plugin precedence (registration
93
+ # order, announce proxy): different plugins always
94
+ # break the tie; same plugin (nil included) tied = CapabilityAmbiguous.
95
+ def pick_top(candidates, capability)
96
+ indexed = providers(capability).each_with_index.to_h { |p, i| [p, i] }
97
+
98
+ rank = ->(p) { p.priority.nil? ? [0, 0] : [1, p.priority] }
99
+ top_rank = candidates.map(&rank).max
100
+ top = candidates.select { |p| rank.call(p) == top_rank }
101
+
102
+ return top.first if top.size == 1
103
+
104
+ groups = top.group_by(&:plugin).values
105
+ if groups.any? { |g| g.size > 1 }
106
+ raise CapabilityAmbiguous.new(capability: capability, candidates: top)
107
+ end
108
+
109
+ groups.min_by { |g| indexed[g.first] }.first
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # Hands a finished turn's answer to a Shape B channel. The turn
7
+ # ended; the recipient is not on any connection; the reply has to travel out of
8
+ # band and survive a crash on the way. Three moves, in this order, and the order
9
+ # is the whole design:
10
+ #
11
+ # 1. RECORD at the turn's terminal (durable, `pending`).
12
+ # 2. CLAIM before the HTTP call (`pending -> delivering`, atomic). A crash
13
+ # between the claim and the POST loses that delivery; it does not duplicate
14
+ # it. At-most-once, stated rather than papered over — the same honest scope
15
+ # the async-delegation path already has.
16
+ # 3. RETRY, bounded and explicit. Unlike a delegation, the recipient is a third
17
+ # party with outages, so "keep trying" is a real requirement and "keep trying
18
+ # forever" is a real outage of ours.
19
+ #
20
+ # It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
21
+ # grows one, the thing to do is take a real queue, not to finish building this.
22
+ class ChannelDelivery
23
+ MAX_ATTEMPTS = 3
24
+ # Waits BETWEEN attempts, so attempt 1 is immediate. Short on purpose: a
25
+ # customer waiting on WhatsApp is the deadline, not the consumer's SLA.
26
+ BACKOFF_SECONDS = [1, 5].freeze
27
+
28
+ def initialize(channels:, outbox:, session_store:, event_stream: nil,
29
+ max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil)
30
+ @channels = channels
31
+ @outbox = outbox
32
+ @session_store = session_store
33
+ @event_stream = event_stream
34
+ @max_attempts = max_attempts
35
+ @backoff = Array(backoff)
36
+ @sleeper = sleeper || method(:default_sleep)
37
+ end
38
+
39
+ # The turn committed an answer. -> the Delivery to dispatch, or nil when there
40
+ # is nothing to deliver, which is the common case and must stay cheap:
41
+ # · the turn did not come in through a channel,
42
+ # · the channel is Shape A (answers on its own stream — no `deliver`),
43
+ # · the answer is empty (a turn that died mid-message published nothing, and
44
+ # half a sentence was never an answer),
45
+ # · or we do not know who to send it to.
46
+ def record(task:, channel_id:, content:)
47
+ return nil if content.to_s.strip.empty?
48
+
49
+ channel = @channels&.find(channel_id)
50
+ return nil unless channel.respond_to?(:deliver)
51
+
52
+ to = recipient(channel, task.session_id)
53
+ return nil if to.nil? || to.empty?
54
+
55
+ @outbox.create(
56
+ channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
57
+ payload: { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
58
+ "content" => content.to_s }
59
+ )
60
+ end
61
+
62
+ # Claim + POST + bounded retry. Safe to call twice: the second caller loses the
63
+ # claim and returns without touching the recipient.
64
+ def deliver(id)
65
+ return false unless @outbox.claim(id)
66
+
67
+ delivery = @outbox.find(id)
68
+ channel = @channels&.find(delivery&.channel)
69
+ # The channel vanished between the record and the dispatch (a plugin was
70
+ # disabled, the deployment was reconfigured). Nothing can send this; leave it
71
+ # terminal so the boot sweep does not spin on it forever.
72
+ if channel.nil? || !channel.respond_to?(:deliver)
73
+ return finish(@outbox.mark_failed(id, error: "channel '#{delivery&.channel}' is not registered"))
74
+ end
75
+
76
+ attempt(delivery, channel)
77
+ end
78
+
79
+ # Boot: re-drive what a previous process recorded and never claimed. Records
80
+ # left `delivering` are deliberately NOT swept — that process may have POSTed
81
+ # before it died, and replaying is the duplicate the claim exists to prevent.
82
+ # -> { dispatched: [ids] }
83
+ def sweep
84
+ dispatched = @outbox.pending.map do |delivery|
85
+ deliver(delivery.id)
86
+ delivery.id
87
+ end
88
+ { dispatched: dispatched }
89
+ end
90
+
91
+ private
92
+
93
+ def attempt(delivery, channel)
94
+ last_error = nil
95
+
96
+ @max_attempts.times do |i|
97
+ @sleeper.call(@backoff[i - 1]) if i.positive? && @backoff[i - 1]
98
+
99
+ begin
100
+ status = channel.deliver(delivery.payload, to: delivery.to, delivery_id: delivery.id)
101
+ return finish(@outbox.mark_delivered(delivery.id)) if (200..299).cover?(status.to_i)
102
+
103
+ last_error = "HTTP #{status}"
104
+ rescue Insika::DeliveryError => e
105
+ last_error = e.message
106
+ end
107
+ @outbox.record_attempt(delivery.id, error: last_error)
108
+ end
109
+
110
+ finish(@outbox.mark_failed(delivery.id, error: last_error))
111
+ end
112
+
113
+ # The consumer's own key for this conversation. Written into the session's vars
114
+ # when the channel minted it; the channel's own id parser is the fallback
115
+ # for a session created before those vars existed.
116
+ def recipient(channel, session_id)
117
+ session = session_id && @session_store.find(session_id)
118
+ vars = session&.vars || {}
119
+ from_vars = vars["external_id"] || vars[:external_id]
120
+ return from_vars.to_s if Insika::Coercion.presence(from_vars)
121
+
122
+ channel.respond_to?(:external_id_from) ? channel.external_id_from(session_id) : nil
123
+ end
124
+
125
+ def finish(delivery)
126
+ emit(delivery)
127
+ delivery.status == :delivered
128
+ end
129
+
130
+ def emit(delivery)
131
+ return unless @event_stream
132
+
133
+ data = { channel: delivery.channel, outbox_id: delivery.id,
134
+ status: delivery.status.to_s, attempts: delivery.attempts,
135
+ error: delivery.last_error }
136
+ meta = { task_id: delivery.task_id, session_id: delivery.session_id,
137
+ at: Time.now.utc.iso8601 }
138
+ # :delivery_failed is the ALERT face of a failed delivery (WS6) — emitted
139
+ # alongside :channel_delivered so the delivery audit stream is unchanged.
140
+ @event_stream.emit(Insika::Event.new(type: :channel_delivered, data: data, meta: meta))
141
+ if delivery.status == :failed
142
+ @event_stream.emit(Insika::Event.new(type: :delivery_failed, data: data, meta: meta))
143
+ end
144
+ end
145
+
146
+ # Async when there is a reactor (production: the retry must not block the
147
+ # worker), plain sleep otherwise (boot sweep before the reactor, specs).
148
+ def default_sleep(seconds)
149
+ task = defined?(Async::Task) ? Async::Task.current? : nil
150
+ task ? task.sleep(seconds) : sleep(seconds)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The channels this deployment speaks, by id. A `Registry` like
5
+ # tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
6
+ # half-registered plugin back exactly as it does for a tool.
7
+ #
8
+ # The id is the URL segment (`/channels/relay/events`), which is why lookup here
9
+ # is a `find` and not a `resolve`: an unknown segment is a 404, not an exception
10
+ # the transport has to rescue.
11
+ class ChannelRegistry < Registry
12
+ # -> the channel instance | nil (unknown id). A blank id never matches.
13
+ def find(id)
14
+ name = id.to_s
15
+ return nil if name.empty?
16
+
17
+ resolve(name)
18
+ rescue Insika::NotFoundError
19
+ nil
20
+ end
21
+
22
+ # Does this channel deliver out of band (Shape B)? A Shape A channel answers on
23
+ # the request's own stream and has no `deliver`, so nothing is ever written to
24
+ # the outbox for it. Duck-typed, like every other seam in the engine.
25
+ def deliverable?(id)
26
+ channel = find(id)
27
+ !channel.nil? && channel.respond_to?(:deliver)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Insika
9
+ module Channels
10
+ # The channel for an adopter who ALREADY owns a messaging integration
11
+ # A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
12
+ # engine for the TURN, not for the platform. Two routes and an envelope —
13
+ #
14
+ # consumer --POST /channels/relay/events--> engine acked now, never the reply
15
+ # consumer <--POST <deliver_url>---------- engine the reply, when there is one
16
+ #
17
+ # — and everything platform-shaped stays theirs: the 24-hour window, template
18
+ # approval, media, read receipts, and how markdown becomes WhatsApp formatting
19
+ # That is the promise, not the limitation: an integration someone has
20
+ # already tuned for years does not have to move for them to adopt the engine.
21
+ # A relay that starts growing template logic has stopped being a relay.
22
+ #
23
+ # It is also the cheapest possible Shape B, which is why it is built first:
24
+ # both ends are ours, so there is no third-party signature scheme and no
25
+ # rendering to get wrong at the same time as the durability. What it DOES
26
+ # exercise — the outbox, the claim, bounded retry, inbound dedup — is what
27
+ # Slack and native WhatsApp inherit untouched.
28
+ #
29
+ # R1/R2 hold: this object translates and authenticates, and does nothing else.
30
+ # No Executor, no store, no RubyLLM; it may refuse a request, never grant a
31
+ # capability.
32
+ class Relay
33
+ DEFAULT_ID = "relay"
34
+ DEFAULT_TIMEOUT = 10
35
+
36
+ attr_reader :id
37
+
38
+ # The bundled relay as an operator configures it: three env vars, of which the
39
+ # token is the SWITCH — no token, no channel, so there is no way to end up with
40
+ # this route mounted and open. -> Relay | nil.
41
+ #
42
+ # Shared by every composition root on purpose: the DSL front door has to reach
43
+ # the same feature as `config.ru`, or the docs are true of only one of them.
44
+ def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
45
+ token = Insika::EnvSchema.read("INSIKA_RELAY_TOKEN", env)
46
+ return nil unless Insika::EnvSchema.present?(token)
47
+
48
+ new(inbound_token: token,
49
+ deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
50
+ deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
51
+ http: http, allow_http: allow_http, allow_private: allow_private)
52
+ end
53
+
54
+ # inbound_token: shared secret the consumer sends us (Bearer). Blank ->
55
+ # the channel answers :disabled to every request, fail-closed
56
+ # by construction rather than open by omission.
57
+ # deliver_url: where the reply goes. Blank -> nothing is ever delivered
58
+ # (the channel still accepts inbound; the outbox records the
59
+ # reply and the delivery fails loudly instead of silently).
60
+ # deliver_token: Bearer we send THEM. Optional: a consumer on a private
61
+ # network may authenticate us another way.
62
+ def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
63
+ id: DEFAULT_ID, allow_http: false, allow_private: false,
64
+ timeout: DEFAULT_TIMEOUT)
65
+ @id = id.to_s
66
+ @inbound_token = inbound_token.to_s
67
+ @deliver_url = deliver_url.to_s
68
+ @deliver_token = deliver_token.to_s
69
+ @http = http || Insika::HttpClient.new
70
+ @allow_http = allow_http
71
+ @allow_private = allow_private
72
+ @timeout = timeout
73
+ end
74
+
75
+ # -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
76
+ # RFC sketched one): a status code is the transport's vocabulary, and keeping
77
+ # it out of here is what lets this class be tested without Rack and read
78
+ # without knowing HTTP.
79
+ def authenticate(req)
80
+ return :disabled if @inbound_token.empty?
81
+
82
+ provided = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer (.+)\z/, 1]
83
+ return :unauthorized if provided.nil?
84
+
85
+ secure_compare(@inbound_token, provided) ? :ok : :unauthorized
86
+ end
87
+
88
+ # Inbound envelope -> the fields the mount turns into a `:send_message`.
89
+ # STRING keys in, because the consumer's `vars` are arbitrary data keys.
90
+ #
91
+ # { "agent": "support", "external_id": "5511999998888",
92
+ # "event_id": "wamid.HBg…", "message": "queria saber do pedido",
93
+ # "vars": { … } }
94
+ def parse(_req, body:)
95
+ body = body.is_a?(Hash) ? body : {}
96
+ agent = string(body["agent"])
97
+ external_id = string(body["external_id"])
98
+ message = string(body["message"])
99
+
100
+ raise Insika::ValidationError, "agent is required" if agent.empty?
101
+ raise Insika::ValidationError, "external_id is required" if external_id.empty?
102
+ raise Insika::ValidationError, "message is required" if message.strip.empty?
103
+
104
+ vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
105
+ { agent: agent, external_id: external_id, message: message,
106
+ event_id: presence(body["event_id"]), vars: vars }
107
+ end
108
+
109
+ # the engine namespaces the platform's conversation key, so a
110
+ # Slack channel id and a phone number can never collide, an operator can see
111
+ # where a conversation came from, and an id minted for one channel cannot be
112
+ # used to read another's session.
113
+ def session_id_for(external_id) = "#{@id}:#{external_id}"
114
+
115
+ # The reverse: what the consumer called this conversation. Reads off the
116
+ # session id so a delivery needs no extra state.
117
+ def external_id_from(session_id)
118
+ s = session_id.to_s
119
+ s.start_with?("#{@id}:") ? s.delete_prefix("#{@id}:") : nil
120
+ end
121
+
122
+ # Hands ONE reply to the consumer's callback. -> the HTTP status (the
123
+ # dispatcher decides what 2xx means); raises DeliveryError when the request
124
+ # could not be made at all.
125
+ #
126
+ # `X-Insika-Delivery` is the outbox id: a stable idempotency key, so a
127
+ # consumer that receives the same delivery twice (we retried after a timeout
128
+ # that actually landed) can drop the second one.
129
+ def deliver(payload, to:, delivery_id: nil)
130
+ raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
131
+
132
+ if (reason = egress_violation)
133
+ raise Insika::DeliveryError, "egress blocked for deliver_url: #{reason}"
134
+ end
135
+
136
+ response = @http.request(method: :post, url: @deliver_url, timeout: @timeout,
137
+ headers: headers(delivery_id),
138
+ body: JSON.generate(payload.merge("external_id" => to.to_s)))
139
+ response[:status].to_i
140
+ rescue Insika::DeliveryError
141
+ raise
142
+ rescue StandardError => e
143
+ raise Insika::DeliveryError, "#{e.class}: #{e.message}"
144
+ end
145
+
146
+ private
147
+
148
+ # Resolved on EVERY call, not once at boot: a hostname that answered a public
149
+ # address yesterday can answer 169.254.169.254 today, and this POST carries
150
+ # the customer's conversation.
151
+ def egress_violation
152
+ Insika::EgressGuard.violation(@deliver_url, allow_http: @allow_http,
153
+ allow_private: @allow_private,
154
+ host_allowlist: [URI.parse(@deliver_url).host].compact)
155
+ rescue URI::InvalidURIError
156
+ "invalid URL"
157
+ end
158
+
159
+ def headers(delivery_id)
160
+ h = { "content-type" => "application/json" }
161
+ h["authorization"] = "Bearer #{@deliver_token}" unless @deliver_token.empty?
162
+ h["x-insika-delivery"] = delivery_id.to_s if delivery_id
163
+ h
164
+ end
165
+
166
+ # Constant time, and length-safe: comparing the digests means an attacker
167
+ # learns nothing from how long the check took, not even the token's length.
168
+ # OpenSSL rather than Rack::Utils so the engine's lib/ keeps no web framework
169
+ # in its load path.
170
+ def secure_compare(a, b)
171
+ OpenSSL.fixed_length_secure_compare(Digest::SHA256.digest(a), Digest::SHA256.digest(b))
172
+ end
173
+
174
+ def string(value) = value.to_s
175
+ def presence(value) = Insika::Coercion.presence(value)
176
+ end
177
+ end
178
+ end