insika 0.1.0 → 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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. data/lib/insika/server/admin_auth.rb +0 -29
@@ -16,11 +16,24 @@ module Insika
16
16
  :id, :model, :provider,
17
17
  :base_prompt, :prompt_files,
18
18
  :tools_allow, :tools_deny,
19
- :tools_allow_groups, # per-GROUP allowlist (Phase 7/D4/F5, Stage C):
19
+ :tools_allow_groups, # per-GROUP allowlist:
20
20
  # union with tools_allow; deny wins; both
21
21
  # nil = all (parity). Expands to the group's
22
22
  # tools in the ToolAllowlist policy.
23
23
  :skills,
24
+ :skills_eager, # progressive disclosure OFF, wholly or in part:
25
+ # nil/false = level 1 + load_skill (parity); true = every
26
+ # allowed skill; [names] = exactly these. An eager skill's
27
+ # BODY enters the prompt each turn, it leaves the
28
+ # <available_skills> catalog and load_skill refuses it.
29
+ # Removes the activation DECISION (no miss rate) at the
30
+ # cost of the bodies' tokens — measure them against
31
+ # context_budget before turning it on. Same opt-in as
32
+ # `memory`. It lives HERE and not in the SKILL.md
33
+ # frontmatter because skills are shared between agents:
34
+ # a per-skill flag forced one decision onto every
35
+ # allowlist holding the skill. NOT `Allowlist`
36
+ # semantics — nil means NONE here (SkillCatalog#eager_for).
24
37
  :context_providers, # provider allowlist
25
38
  :workflows_allow, # applied by WorkflowAllowlist
26
39
  :policies, # names in the Policy Registry
@@ -30,7 +43,7 @@ module Insika
30
43
  :capabilities, # intents the agent can trigger.
31
44
  # nil = NONE (opt-in, see above).
32
45
  :subagents, # allowlist of child agent ids this agent MAY spawn
33
- # (RFC-0010, item 21). CAPACITY field — NEVER inherits;
46
+ # CAPACITY field — NEVER inherits;
34
47
  # opt-in like `capabilities`: nil/absent = NONE (do NOT
35
48
  # "fix" to nil = all). Present => the `spawn_subagent`
36
49
  # system tool is wired (ChatBuilder), gated by this set.
@@ -40,7 +53,7 @@ module Insika
40
53
  :memory, # cross-session memory.
41
54
  # nil/false = OFF (parity: provider []; the `remember`
42
55
  # tool not wired); true = ON. Same opt-in as capabilities.
43
- :prompt_caching, # Anthropic prompt caching (§11 R3): nil/false = OFF
56
+ :prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
44
57
  # (parity); true = ON. Same opt-in as `memory`. When ON
45
58
  # AND the resolved provider is Anthropic, ChatBuilder sets
46
59
  # ONE cache breakpoint at the end of the system block
@@ -50,34 +63,79 @@ module Insika
50
63
  # a context provider injecting volatile content into
51
64
  # :system turns every turn into a paid cache WRITE with
52
65
  # no read hit. Enable only for stable-system agents.
53
- :params, # LLM generation params (v2, §10): a Hash with
66
+ :tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
67
+ # history (A3/C3): nil/false = OFF (parity); true = ON.
68
+ # Same opt-in as `memory`. When ON, the history the
69
+ # Session provider seeds replaces byte-identical repeated
70
+ # tool results with a compact back-reference (first
71
+ # occurrence stays full) — no LLM involved. CHANGES WHAT
72
+ # THE MODEL SEES: an older full result is only the first
73
+ # occurrence; a model that wants an older detail re-calls
74
+ # the tool. Cheap half of compaction for bloated histories.
75
+ :params, # LLM generation params: a Hash with
54
76
  # temperature/max_tokens/thinking, applied to the chat at
55
77
  # stage 5. {} = provider defaults (parity).
56
- :model_policy, # governance of WHICH models the agent may use (v2, §10):
78
+ :budget, # spend caps per (tenant, agent) over
79
+ # CALENDAR windows (WS2): { "daily" => int,
80
+ # "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
81
+ # HARD is the default: absent/"soft": false, a turn
82
+ # arriving at/over the cap fails with Insika::BudgetExceeded
83
+ # and the envelope quotes `budget_exceeded` + retry_after.
84
+ # "soft": true crosses the cap and still runs
85
+ # (one budget_warning event per window + a note in the
86
+ # context).
87
+ # Tokens count the billed spend (input+output+cached+
88
+ # cache_creation). nil/absent = no budget (parity).
89
+ :reliability, # the provider-interaction reliability policy (WS3):
90
+ # { "retries" => 3, "backoff" => "exponential",
91
+ # "fallback" => ["gpt-4o-mini", ...],
92
+ # "circuit_breaker" => { "after" => 10, "within" => 60,
93
+ # "cooldown" => 300 }, "timeout" => 30 }. Data, never
94
+ # DSL: retries + exponential backoff on :retryable /
95
+ # :rate_limited_* failures (never :fatal), mid-turn
96
+ # rotation across the fallback chain (profile's first,
97
+ # then the platform's resolved fallbacks), and a circuit
98
+ # breaker per (tenant, provider/model) that fail-fasts
99
+ # with circuit_open + retry_after once the window count
100
+ # trips. nil/absent = the plain single attempt (parity).
101
+ :alerts, # operator alert delivery (WS6): { "webhook" => url }.
102
+ # When present, the agent's budget_warning /
103
+ # breaker_open / delivery_failed events are POSTed to
104
+ # the URL as JSON (outbox + claim, at-most-once).
105
+ # nil/absent = no webhook (parity).
106
+ :stuck_signal, # the agent may signal it cannot proceed (WS5):
107
+ # nil/false = OFF (parity — the signal_stuck system
108
+ # tool is not wired); true = ON (the model may call
109
+ # signal_stuck, which ends the turn with
110
+ # `outcome: :stuck` + a final message + a :turn_stuck
111
+ # event the consumer acts on). Same opt-in as
112
+ # `memory`. What "stuck" MEANS is the consumer's call
113
+ # (escalation via CRM/operator), never the engine's.
114
+ :model_policy, # governance of WHICH models the agent may use:
57
115
  # { "allow" => [refs] }. nil = NO fence (all models —
58
116
  # parity). Enforced on the RESOLVED model (ModelResolver).
59
- :guardrails, # content-safety config (RFC-0009 §3.3): { input:, output:,
117
+ :guardrails, # content-safety config: { input:, output:,
60
118
  # moderator:, strictness: }. OPT-IN like capabilities —
61
119
  # nil/absent = the conservative default (Safety::Config:
62
120
  # deterministic on, moderator off). Parsed, never a policy.
63
- :sandbox, # confined-execution config (item 35, §4.6):
121
+ :sandbox, # confined-execution config:
64
122
  # { provider: "local"|"docker", root:, timeout:, ...+provider
65
123
  # keys }. Declarative provider selection (config-over-code) —
66
124
  # consumed by Insika::Sandbox.build. {} = absent (a
67
125
  # deployment builds a `local` sandbox by default). It is
68
126
  # CONFIG, never a policy — it does not decide security by
69
127
  # itself; the FS boundary + approvals do.
70
- :refinement, # self-improvement config (RFC-0013 §3.8):
128
+ :refinement, # self-improvement config:
71
129
  # { mode: "report"|"propose"|"auto_apply", window: {…},
72
130
  # files: [allowlist], proposers: [refs], budget: {tokens:},
73
131
  # auto_apply_max_edits:, max_findings:, … }. nil/absent =
74
- # REPORT-ONLY (phase A writes nothing to the agent, so
132
+ # REPORT-ONLY (writes nothing to the agent, so
75
133
  # reading your own traces needs no opt-in); `propose`
76
134
  # and above must be enabled explicitly. It is CONFIG,
77
135
  # never a policy — the write allowlist it carries is
78
136
  # enforced by the applier, not by this field.
79
137
  :capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
80
- # (RFC-0014 §3.5): %w[promotions human_handoff
138
+ # %w[promotions human_handoff
81
139
  # b2b_pricing]. An eval case declares what it
82
140
  # `requires` and is SKIPPED — never failed — where
83
141
  # the deployment lacks it, which is what makes one
@@ -106,7 +164,7 @@ module Insika
106
164
  # default to inherit.
107
165
  :metadata # free-form agent metadata, stable per agent
108
166
  # (from the pack `agent.config.json`). Home of the `store_id`
109
- # that becomes turn context (ctx.store_id, Phase 6/D2).
167
+ # that becomes turn context (ctx.store_id).
110
168
  # It is NOT a policy — never decides security. {} = absent.
111
169
  )
112
170
 
@@ -116,29 +174,34 @@ module Insika
116
174
  DEFAULT_LIMITS = {
117
175
  turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
118
176
  context_budget: 8_000, max_tool_calls: 50,
177
+ # consecutive identical (tool, args) calls that trigger the ONE
178
+ # loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
179
+ max_tool_repeat: 3,
119
180
  approval_timeout: 3_600, # cap on the wait for human approval (~1h)
120
- # Item 30: parallel tool calls. ONE number is both the switch and the cap
181
+ # parallel tool calls. ONE number is both the switch and the cap
121
182
  # (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
122
183
  # It sits next to tool_timeout/max_tool_calls because it is the third bound
123
184
  # on tool execution. Read through TurnState#tool_concurrency, which also
124
- # applies the approval gate (D3).
185
+ # applies the approval gate.
125
186
  tool_concurrency: 1
126
187
  }.freeze
127
188
 
128
- # `model` is OPTIONAL as of v2 (§10): an agent without one resolves the
189
+ # `model` is OPTIONAL as of v2: an agent without one resolves the
129
190
  # platform `default_model` (Settings) at turn start via the ModelResolver.
130
191
  def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
131
192
  tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
132
- context_providers: nil, workflows_allow: nil,
193
+ skills_eager: nil, context_providers: nil, workflows_allow: nil,
133
194
  policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
134
195
  capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
135
- prompt_caching: nil,
196
+ prompt_caching: nil, tool_output_compression: nil,
136
197
  params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
137
- refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {})
198
+ refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
199
+ budget: nil, reliability: nil, alerts: nil, stuck_signal: nil)
138
200
  new(
139
201
  id: id, model: model, provider: provider, base_prompt: base_prompt,
140
202
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
141
203
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
204
+ skills_eager: skills_eager,
142
205
  context_providers: context_providers, workflows_allow: workflows_allow,
143
206
  policies: Array(policies), prompt_refs: Array(prompt_refs),
144
207
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
@@ -147,7 +210,7 @@ module Insika
147
210
  # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
148
211
  subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
149
212
  tools_deferred: tools_deferred, memory: memory,
150
- prompt_caching: prompt_caching,
213
+ prompt_caching: prompt_caching, tool_output_compression: tool_output_compression,
151
214
  # The free-form hashes arrive with symbol keys (internal build) OR string
152
215
  # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
153
216
  # here — the single front door every profile passes through — so no reader
@@ -161,7 +224,11 @@ module Insika
161
224
  # symbol/string mix there would be a silent miss.
162
225
  capabilities_declared: Array(capabilities_declared).map(&:to_s),
163
226
  edge_stream: Coercion.deep_stringify(edge_stream || {}),
164
- metadata: Coercion.deep_stringify(metadata || {})
227
+ metadata: Coercion.deep_stringify(metadata || {}),
228
+ budget: Coercion.deep_stringify(budget),
229
+ reliability: Coercion.deep_stringify(reliability),
230
+ alerts: Coercion.deep_stringify(alerts),
231
+ stuck_signal: stuck_signal
165
232
  )
166
233
  end
167
234
 
@@ -173,8 +240,8 @@ module Insika
173
240
  # store_id of the turn context (ctx.store_id): lives in `metadata` (stable
174
241
  # per store, comes from the pack). `build` string-keys metadata, so a plain
175
242
  # string lookup is enough. nil = absent (the data-tool emits an empty header).
176
- # It is NOT achei-specific: `store_id` is a field of the turn-context contract
177
- # (§5), generic per project.
243
+ # It is NOT consumer-specific: `store_id` is a field of the turn-context contract
244
+ # generic per project.
178
245
  def store_id = (metadata || {})["store_id"]
179
246
 
180
247
  # May this channel (:thinking / :intermediate) cross to the customer? Tolerant
@@ -182,7 +249,7 @@ module Insika
182
249
  # of anything else being absent: the safe reading is the default one.
183
250
  def stream_public?(channel)
184
251
  v = (edge_stream || {})[channel.to_s]
185
- [true, "true", "1", "yes", "on"].include?(v)
252
+ Coercion.truthy?(v)
186
253
  end
187
254
  end
188
255
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Operator alerts to a webhook (WS6): the events `:budget_warning`,
8
+ # `:breaker_open` and `:delivery_failed` are answered per AGENT — a profile
9
+ # declaring `alerts: { "webhook" => url }` gets its alerts POSTed there as
10
+ # JSON. The delivery reuses the outbox + claim mechanism whole
11
+ # (`ChannelDelivery`): the handler only WRITES the outbox row; the existing
12
+ # tick sweep and boot recovery claim and POST it, at-most-once with bounded
13
+ # retry, via a registered `Channels::Webhook`. The engine transports the event
14
+ # and does not interpret it — a Slack/CRM adapter is the consumer's.
15
+ #
16
+ # Started as a child of the turn supervisor (like the tick) in serving mode;
17
+ # tests drive `handle` directly.
18
+ class AlertDispatcher
19
+ ALERT_TYPES = %i[budget_warning breaker_open delivery_failed].freeze
20
+
21
+ def initialize(event_stream:, outbox:, channels:, profiles:, task_store: nil, http:)
22
+ @event_stream = event_stream
23
+ @outbox = outbox
24
+ @channels = channels
25
+ @profiles = profiles
26
+ @task_store = task_store
27
+ @http = http
28
+ @webhook_ids = {} # url -> registered channel id (one webhook per URL)
29
+ # WS6 (boot recovery): webhook channels are derived from PROFILE config,
30
+ # not from events. Registering lazily (on the first alert) means a pending
31
+ # outbox row a crashed process left is swept at boot against an EMPTY
32
+ # registry and marked failed terminal. Pre-registering every configured
33
+ # URL at wiring time lets the boot sweep find the channel and deliver.
34
+ register_all_webhooks
35
+ end
36
+
37
+ # Serving: a long-lived consumer that answers every alert event. Drains on
38
+ # the supervisor fiber (blocks on the queue — no spin), exactly like the tick.
39
+ # It subscribes TYPED (only the alert events enter its queue — it answers
40
+ # payloads a full-traffic stream would otherwise overflow away) and, on an
41
+ # overflow close, RE-SUBSCRIBES: a consumer that never re-binds is how alerts
42
+ # stop in silence (WS6).
43
+ def start(parent:)
44
+ parent.async do |t|
45
+ t.annotate("insika-alerts")
46
+ loop do
47
+ subscription = @event_stream.subscribe(types: ALERT_TYPES)
48
+ subscription.each { |event| handle(event) }
49
+ # the subscription closed (its overflow path) — alerts must not die here
50
+ end
51
+ end
52
+ true
53
+ end
54
+
55
+ # The event -> outbox row. Cheap (one transactional write); the DELIVERY is
56
+ # the tick's job. Never raises: an alerting failure must not break the turn.
57
+ def handle(event)
58
+ type = event.type.to_s.to_sym
59
+ return unless ALERT_TYPES.include?(type)
60
+
61
+ agent = agent_for(event)
62
+ return if agent.nil?
63
+
64
+ profile = @profiles.respond_to?(:fetch) ? @profiles.fetch(agent.to_s) : nil
65
+ return if profile.nil?
66
+
67
+ url = profile&.respond_to?(:alerts) ? profile.alerts&.dig("webhook") : nil
68
+ return if Coercion.blank?(url)
69
+
70
+ record_alert(agent: agent.to_s, url: url.to_s, event: event)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ private
76
+
77
+ # The agent the alert belongs to: the event carries it for the alerts the
78
+ # engine emits with context (budget_warning / breaker_open); a
79
+ # delivery_failed resolves its task's command. Guards the loop: a webhook's
80
+ # OWN delivery failing is not re-alerted.
81
+ def agent_for(event)
82
+ case event.type.to_sym
83
+ when :delivery_failed
84
+ channel = event.data[:channel]
85
+ return nil if channel.to_s.start_with?("webhook:") # loop guard
86
+ agent_for_task(event.meta[:task_id])
87
+ else
88
+ event.data[:agent] || agent_for_task(event.meta[:task_id])
89
+ end
90
+ end
91
+
92
+ def agent_for_task(task_id)
93
+ return nil if task_id.nil? || @task_store.nil?
94
+
95
+ task = @task_store.find(task_id.to_s)
96
+ command = task&.respond_to?(:command) ? task.command : nil
97
+ return nil unless command.is_a?(Hash)
98
+
99
+ payload = command["payload"] || command[:payload] || {}
100
+ payload["agent"] || payload[:agent]
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ # The event's durable record, as the CHANNEL would see it. `to` is the
106
+ # webhook URL; `payload` is the event itself (type/data/meta).
107
+ def record_alert(agent:, url:, event:)
108
+ channel = webhook_id(url)
109
+ @outbox.create(
110
+ channel: channel, to: url,
111
+ task_id: event.meta[:task_id], session_id: event.meta[:session_id],
112
+ payload: { "type" => event.type.to_s, "data" => event.data,
113
+ "meta" => event.meta, "agent" => agent }
114
+ )
115
+ end
116
+
117
+ # One channel per URL, registered so ChannelDelivery.sweep can claim it.
118
+ def webhook_id(url)
119
+ @webhook_ids[url] ||= begin
120
+ id = "webhook:#{Digest::SHA1.hexdigest(url)[0, 8]}"
121
+ @channels.register(id, Channels::Webhook.new(url, http: @http))
122
+ id
123
+ end
124
+ end
125
+
126
+ # Boot face of `webhook_id`: register every configured URL up front (at
127
+ # wiring time, before the boot recovery's channel sweep runs). The url is
128
+ # PROFILE data, so it is known before any alert ever fires.
129
+ def register_all_webhooks
130
+ profiles = @profiles.respond_to?(:all) ? @profiles.all : []
131
+ profiles.each do |profile|
132
+ next unless profile&.respond_to?(:alerts)
133
+
134
+ url = profile.alerts&.dig("webhook")
135
+ webhook_id(url.to_s) unless Coercion.blank?(url)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -3,8 +3,8 @@
3
3
  require "time"
4
4
 
5
5
  module Insika
6
- # The ACCEPTED state of an agent's golden set (RFC-0008 §3.4, promoted to a store
7
- # by RFC-0013 §3.7.3). One record per agent in the ConfigStore (scope "baselines"):
6
+ # The ACCEPTED state of an agent's golden set (promoted to a store
7
+ # by). One record per agent in the ConfigStore (scope "baselines"):
8
8
  #
9
9
  # { "at" => iso8601, "cases" => { "<case id>" => { "pass" => bool, "score" => n } } }
10
10
  #
@@ -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
@@ -3,7 +3,7 @@
3
3
  require "time"
4
4
 
5
5
  module Insika
6
- # Hands a finished turn's answer to a Shape B channel (RFC-0011 §6.5). The turn
6
+ # Hands a finished turn's answer to a Shape B channel. The turn
7
7
  # ended; the recipient is not on any connection; the reply has to travel out of
8
8
  # band and survive a crash on the way. Three moves, in this order, and the order
9
9
  # is the whole design:
@@ -41,7 +41,7 @@ module Insika
41
41
  # · the turn did not come in through a channel,
42
42
  # · the channel is Shape A (answers on its own stream — no `deliver`),
43
43
  # · the answer is empty (a turn that died mid-message published nothing, and
44
- # half a sentence was never an answer — P19),
44
+ # half a sentence was never an answer),
45
45
  # · or we do not know who to send it to.
46
46
  def record(task:, channel_id:, content:)
47
47
  return nil if content.to_s.strip.empty?
@@ -111,7 +111,7 @@ module Insika
111
111
  end
112
112
 
113
113
  # The consumer's own key for this conversation. Written into the session's vars
114
- # when the channel minted it (§4.3); the channel's own id parser is the fallback
114
+ # when the channel minted it; the channel's own id parser is the fallback
115
115
  # for a session created before those vars existed.
116
116
  def recipient(channel, session_id)
117
117
  session = session_id && @session_store.find(session_id)
@@ -130,14 +130,17 @@ module Insika
130
130
  def emit(delivery)
131
131
  return unless @event_stream
132
132
 
133
- @event_stream.emit(Insika::Event.new(
134
- type: :channel_delivered,
135
- data: { channel: delivery.channel, outbox_id: delivery.id,
136
- status: delivery.status.to_s, attempts: delivery.attempts,
137
- error: delivery.last_error },
138
- meta: { task_id: delivery.task_id, session_id: delivery.session_id,
139
- at: Time.now.utc.iso8601 }
140
- ))
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
141
144
  end
142
145
 
143
146
  # Async when there is a reactor (production: the retry must not block the
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # The channels this deployment speaks, by id (RFC-0011 §4.2). A `Registry` like
4
+ # The channels this deployment speaks, by id. A `Registry` like
5
5
  # tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
6
6
  # half-registered plugin back exactly as it does for a tool.
7
7
  #
@@ -8,7 +8,7 @@ require "uri"
8
8
  module Insika
9
9
  module Channels
10
10
  # The channel for an adopter who ALREADY owns a messaging integration
11
- # (RFC-0011 §6). A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
11
+ # A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
12
12
  # engine for the TURN, not for the platform. Two routes and an envelope —
13
13
  #
14
14
  # consumer --POST /channels/relay/events--> engine acked now, never the reply
@@ -16,7 +16,7 @@ module Insika
16
16
  #
17
17
  # — and everything platform-shaped stays theirs: the 24-hour window, template
18
18
  # approval, media, read receipts, and how markdown becomes WhatsApp formatting
19
- # (§6.3). That is the promise, not the limitation: an integration someone has
19
+ # That is the promise, not the limitation: an integration someone has
20
20
  # already tuned for years does not have to move for them to adopt the engine.
21
21
  # A relay that starts growing template logic has stopped being a relay.
22
22
  #
@@ -106,7 +106,7 @@ module Insika
106
106
  event_id: presence(body["event_id"]), vars: vars }
107
107
  end
108
108
 
109
- # RFC-0011 §4.3 — the engine namespaces the platform's conversation key, so a
109
+ # the engine namespaces the platform's conversation key, so a
110
110
  # Slack channel id and a phone number can never collide, an operator can see
111
111
  # where a conversation came from, and an id minted for one channel cannot be
112
112
  # used to read another's session.
@@ -1,4 +1,4 @@
1
- // Insika web widget (RFC-0011 §5.2).
1
+ // Insika web widget.
2
2
  //
3
3
  // One <script> tag, no framework, no build step, no dependency:
4
4
  //
@@ -176,7 +176,7 @@
176
176
  }
177
177
 
178
178
  // The engine issues the session id; we only ever store the one it gave us
179
- // (RFC-0011 §4.3 — a client that proposes its own id on a public endpoint is
179
+ // (a client that proposes its own id on a public endpoint is
180
180
  // one enumeration away from reading someone else's conversation).
181
181
  function session() {
182
182
  var saved = remembered;