insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "coercion"
4
+ require_relative "tool_definition"
4
5
 
5
6
  module Insika
6
7
  # Single point of per-agent policy.
@@ -63,6 +64,14 @@ module Insika
63
64
  # a context provider injecting volatile content into
64
65
  # :system turns every turn into a paid cache WRITE with
65
66
  # no read hit. Enable only for stable-system agents.
67
+ :tool_persistence, # the engine's "Tool discipline" block in the system
68
+ # prompt (retry weak/empty tool results with a different
69
+ # approach before giving up). THE ONE OPT-OUT FIELD:
70
+ # nil/true = ON (the proven default — every reference
71
+ # harness ships it), false = OFF. Deliberately inverted
72
+ # from the opt-in fields above: the exception here is
73
+ # turning the good behavior OFF, so that is what an
74
+ # operator declares. Read by Context::Providers::Prompt.
66
75
  :tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
67
76
  # history (A3/C3): nil/false = OFF (parity); true = ON.
68
77
  # Same opt-in as `memory`. When ON, the history the
@@ -103,6 +112,14 @@ module Insika
103
112
  # breaker_open / delivery_failed events are POSTed to
104
113
  # the URL as JSON (outbox + claim, at-most-once).
105
114
  # nil/absent = no webhook (parity).
115
+ :routes, # intent routing (WS4): { "route" => "description" or a
116
+ # Hash with description/delegate/stuck/message,
117
+ # "default" => route, "model" => cheap classifier }.
118
+ # When present, the message is classified BEFORE the
119
+ # ask with the cheap model; the route lands on the
120
+ # turn (state.route + :route_classified) and may
121
+ # delegate to an existing agent or end the turn :stuck
122
+ # (WS5). nil/absent = no routing (parity).
106
123
  :stuck_signal, # the agent may signal it cannot proceed (WS5):
107
124
  # nil/false = OFF (parity — the signal_stuck system
108
125
  # tool is not wired); true = ON (the model may call
@@ -111,6 +128,19 @@ module Insika
111
128
  # event the consumer acts on). Same opt-in as
112
129
  # `memory`. What "stuck" MEANS is the consumer's call
113
130
  # (escalation via CRM/operator), never the engine's.
131
+ :outputs, # generated-media output policy (WS9, saída):
132
+ # { "image" => { "model" => …, "size" => "1024x1024" },
133
+ # "tts" => { "model" => "tts-1", "voice" => "alloy",
134
+ # "format" => "mp3" } }. THE AGENT'S HALF of the
135
+ # media-output gate — nil/absent = the agent never
136
+ # generates media (opt-in like `capabilities`, do NOT
137
+ # "fix" to nil = all). The other half is the CHANNEL'S:
138
+ # the request must declare it can receive the media
139
+ # (`channel.capabilities` — "image_output" /
140
+ # "audio_output"); only with BOTH does the model see
141
+ # the generate_image/tts tools (the abstraction admits
142
+ # only what leaks). Generated media rides the turn's
143
+ # `output_parts` in the envelope, never the answer text.
114
144
  :model_policy, # governance of WHICH models the agent may use:
115
145
  # { "allow" => [refs] }. nil = NO fence (all models —
116
146
  # parity). Enforced on the RESOLVED model (ModelResolver).
@@ -162,10 +192,72 @@ module Insika
162
192
  # (WhatsApp) puts the deliberation in front of a
163
193
  # customer; that is the operator's call to make, not a
164
194
  # default to inherit.
165
- :metadata # free-form agent metadata, stable per agent
166
- # (from the pack `agent.config.json`). Home of the `store_id`
167
- # that becomes turn context (ctx.store_id).
168
- # It is NOT a policy — never decides security. {} = absent.
195
+ :grounding, # the pack's grounding policy
196
+ # { "mode" => "flag"|"enforce"|"off",
197
+ # "matcher" => { "sku" => …,
198
+ # "name_keys" => [...] } }. OPT-IN:
199
+ # nil/absent = OFF (parity, zero allocations).
200
+ # Deep-stringified like the other hashes.
201
+ :metadata, # free-form agent metadata, stable per agent
202
+ # (from the pack `agent.config.json`). Home of the `store_id`
203
+ # that becomes turn context (ctx.store_id).
204
+ # It is NOT a policy — never decides security. {} = absent.
205
+ :briefing_fields, # the per-session working-state schema this agent
206
+ # keeps and asks for: a flat [String] of
207
+ # field names the pack declares. []/nil/absent = the
208
+ # feature is OFF (no provider output, no tools — visibly
209
+ # removable). Names are engine-owned store keys
210
+ # and tool text, so they are validated against NAME_RE at
211
+ # build time. Data, never a policy: the engine owns the
212
+ # briefing object, the pack owns the fields.
213
+ :funnel, # the outcome funnel declaration — pack
214
+ # data, exactly like budget/reliability:
215
+ # { "stages" => ["greeted", "qualified", "cart", "paid"],
216
+ # "advance_on" => { "pix_paid" => "paid", … },
217
+ # "primary" => "paid", "attribution_window" => "72h" }.
218
+ # The ENGINE never hard-codes a stage name: the fold,
219
+ # the doctor and the Studio read this declaration (D1).
220
+ # nil/absent = no funnel (parity — nothing folds).
221
+ # Deep-stringified like the other free-form hashes;
222
+ # shape-validated by FunnelDeclaration, never here (D8).
223
+ :followup, # the follow-up declaration — pack data,
224
+ # exactly like budget/funnel:
225
+ # { "arm" => "schedule",
226
+ # "policy" => { "quiet_hours" => { "timezone" => "…",
227
+ # "start" => "21:30", "end" => "09:00" },
228
+ # "max_frequency" => "2/24h",
229
+ # "cancel_keywords" => ["não quero mais contato"],
230
+ # "silence_after_sends" => 3 } }.
231
+ # The engine OWNS the firing, never a policy value (D1);
232
+ # shape-validated by FollowupPolicy, never here (D9).
233
+ # nil/absent = the feature is off (parity).
234
+ # Deep-stringified like the other free-form hashes.
235
+ :distill, # the session-distillation declaration — pack
236
+ # data, exactly like refinement/followup:
237
+ # { "enabled" => bool, "prompt" => "<pack-authored markdown
238
+ # — what counts as a fact for this store>",
239
+ # "model" => "<ref — absent = the platform utility_model>",
240
+ # "idle_hours" => 6, "min_messages" => 3,
241
+ # "max_proposals" => 10 }.
242
+ # The ENGINE assembles the scope from the session; the
243
+ # model only names facts (D1). nil/absent = the feature is
244
+ # off (parity, byte-identical engine). Shape-validated by
245
+ # the command/engine, never here (the refinement precedent).
246
+ # Deep-stringified like the other free-form hashes.
247
+ :harvest # the gated-harvest declaration — pack data,
248
+ # exactly like refinement/distill:
249
+ # { "enabled" => bool,
250
+ # "negative_list" => [ { "rule" => "…", "pattern" => "…",
251
+ # "note" => "…" } ],
252
+ # "miner" => { "model" => "<ref — absent = the platform
253
+ # utility_model>", "window" => { "last_sessions" => N },
254
+ # "max_proposals" => N, "budget" => { "tokens" => N } },
255
+ # "idle_hours" => 24, "min_messages" => 3 }.
256
+ # The ENGINE mines (reads sessions, asks the miner, filters
257
+ # through the negative list + grounding), never authors a
258
+ # rule (D4). nil/absent = the loop is off (parity).
259
+ # Shape-validated by the command/engine/doctor, never here.
260
+ # Deep-stringified like the other free-form hashes.
169
261
  )
170
262
 
171
263
  # Reopened class (not a Data.define block): a constant assigned inside
@@ -193,10 +285,12 @@ module Insika
193
285
  skills_eager: nil, context_providers: nil, workflows_allow: nil,
194
286
  policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
195
287
  capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
196
- prompt_caching: nil, tool_output_compression: nil,
197
- params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
198
- refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
199
- budget: nil, reliability: nil, alerts: nil, stuck_signal: nil)
288
+ prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
289
+ params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
290
+ refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
291
+ budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
292
+ outputs: nil, briefing_fields: nil, grounding: nil, funnel: nil,
293
+ followup: nil, distill: nil, harvest: nil)
200
294
  new(
201
295
  id: id, model: model, provider: provider, base_prompt: base_prompt,
202
296
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
@@ -210,7 +304,8 @@ module Insika
210
304
  # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
211
305
  subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
212
306
  tools_deferred: tools_deferred, memory: memory,
213
- prompt_caching: prompt_caching, tool_output_compression: tool_output_compression,
307
+ prompt_caching: prompt_caching, tool_persistence: tool_persistence,
308
+ tool_output_compression: tool_output_compression,
214
309
  # The free-form hashes arrive with symbol keys (internal build) OR string
215
310
  # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
216
311
  # here — the single front door every profile passes through — so no reader
@@ -228,10 +323,50 @@ module Insika
228
323
  budget: Coercion.deep_stringify(budget),
229
324
  reliability: Coercion.deep_stringify(reliability),
230
325
  alerts: Coercion.deep_stringify(alerts),
231
- stuck_signal: stuck_signal
326
+ routes: Coercion.deep_stringify(routes),
327
+ stuck_signal: stuck_signal,
328
+ outputs: Coercion.deep_stringify(outputs),
329
+ # Flat [String] — same discipline as capabilities_declared: a
330
+ # symbol/string mix would be a silent miss in the provider's known-set.
331
+ briefing_fields: normalize_briefing_fields(briefing_fields),
332
+ # grounding is profile DATA, deep-stringified like the other
333
+ # free-form hashes; parsed into a Grounding per turn by the validator/
334
+ # enforcer. nil = off (parity).
335
+ grounding: Coercion.deep_stringify(grounding),
336
+ # funnel is profile DATA, deep-stringified like the other
337
+ # free-form hashes; parsed into a FunnelDeclaration by the fold/doctor/
338
+ # Studio (shape-validated THERE, never here — D8). nil = no funnel (parity).
339
+ funnel: Coercion.deep_stringify(funnel),
340
+ # followup is profile DATA, deep-stringified like the other
341
+ # free-form hashes; parsed into a FollowupPolicy by the tool/engine/
342
+ # doctor/Studio (shape-validated THERE, never here — D9). nil = off (parity).
343
+ followup: Coercion.deep_stringify(followup),
344
+ # distill is profile DATA, deep-stringified like the other
345
+ # free-form hashes; shape-validated by the command/engine/doctor
346
+ # (never here — the refinement precedent). nil = off (parity).
347
+ distill: Coercion.deep_stringify(distill),
348
+ # harvest is profile DATA, deep-stringified like the other
349
+ # free-form hashes; shape-validated by the command/engine/doctor
350
+ # (never here — the refinement precedent). nil = off (parity).
351
+ harvest: Coercion.deep_stringify(harvest)
232
352
  )
233
353
  end
234
354
 
355
+ # nil -> []; strings; trim + drop empties + uniq (stable order); every name
356
+ # must match ToolDefinition::NAME_RE (\A[a-z][a-z0-9_]*\z) or it is a
357
+ # ValidationError at build time — the names become tool-description text,
358
+ # store keys and context-block lines, so "size ok" or "tamanho do cliente"
359
+ # is refused here, not corrupted later.
360
+ def self.normalize_briefing_fields(list)
361
+ names = Array(list).map { |f| f.to_s.strip }.reject(&:empty?).uniq
362
+ bad = names.reject { |n| ToolDefinition::NAME_RE.match?(n) }
363
+ unless bad.empty?
364
+ raise Insika::ValidationError,
365
+ "briefing_fields must match #{ToolDefinition::NAME_RE.inspect}: #{bad.join(', ')}"
366
+ end
367
+ names
368
+ end
369
+
235
370
  # opt-in for an optional tool = being in the agent's allow list.
236
371
  def tool_opted_in?(name)
237
372
  Array(tools_allow).include?(name)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # — pure: confirmed answer text -> the balloons a progressive
5
+ # channel should POST. Paragraphs are the unit (`\n\n` is the seam E1 cares
6
+ # about); the sentence split fires only as a SOFT cap for one paragraph that
7
+ # grew past `SENTENCE_AFTER`. It never splits inside a fenced code block.
8
+ #
9
+ # No events, no outbox, no `:intermediate` — if it is handed loop narration
10
+ # that is a caller bug; the splitter has no way to tell.
11
+ module BalloonSplitter
12
+ # Soft cap after which a single paragraph is split on sentences. WhatsApp's
13
+ # hard cap is ~4096; this is a readability cap, not a platform one.
14
+ SENTENCE_AFTER = 600
15
+
16
+ module_function
17
+
18
+ # -> [String], at least one when `text` is non-blank, else [].
19
+ def split(text)
20
+ raw = Insika::Coercion.presence(text)
21
+ return [] if raw.nil?
22
+
23
+ paragraphs = raw.split(PARAGRAPH_BREAK)
24
+ balloons = group_fenced(paragraphs)
25
+ balloons.flat_map { |balloon| split_long(balloon) }
26
+ end
27
+
28
+ # A fenced code block (` ``` ` … ` ``` `) spans paragraphs and stays ONE
29
+ # balloon — newlines inside it are code, not a seam. A fence that never
30
+ # closes still ends as one balloon (garbage in, one balloon out).
31
+ def group_fenced(paragraphs)
32
+ balloons = []
33
+ fence = nil
34
+ paragraphs.each do |para|
35
+ piece = para.strip
36
+ next if piece.empty?
37
+
38
+ if fence
39
+ fence << piece
40
+ # a line starting with ``` closes the block, wherever it lands — the
41
+ # close fence of ordinary markdown shares a paragraph with its code.
42
+ if closes_fence?(piece)
43
+ balloons << fence.join("\n\n")
44
+ fence = nil
45
+ end
46
+ elsif piece.lines.first.to_s.start_with?(FENCE)
47
+ fence = [piece]
48
+ # one paragraph may open AND close the block (```ruby\nx = 1\n```) —
49
+ # the closer is a later line of the same piece.
50
+ if closes_fence?(piece, opener: true)
51
+ balloons << fence.join("\n\n")
52
+ fence = nil
53
+ end
54
+ else
55
+ balloons << piece
56
+ end
57
+ end
58
+ balloons << fence.join("\n\n") if fence
59
+ balloons
60
+ end
61
+
62
+ # Does this paragraph close an open fence? Any line starting with the fence
63
+ # marker counts. `opener: true` skips the FIRST line — the line that opened
64
+ # the block, which cannot be its own closer.
65
+ def closes_fence?(piece, opener: false)
66
+ lines = piece.lines
67
+ lines[(opener ? 1 : 0)..].any? { |line| line.start_with?(FENCE) }
68
+ end
69
+
70
+ # The soft cap: a paragraph longer than SENTENCE_AFTER splits after sentence
71
+ # closers followed by whitespace — which is why `3.9s` and `www.` survive
72
+ # (their period is not followed by whitespace) and a decimal does too. A
73
+ # leftover without a closer stays one balloon.
74
+ #
75
+ # Sentences are then RE-GROUPED into ~SENTENCE_AFTER blocks (
76
+ # the split is a cap, not a mandate). Without the regroup, a 680-char
77
+ # paragraph of short sentences would atomize into one balloon per sentence —
78
+ # a paragraph that was ONE bubble becomes 40 WhatsApp messages for no
79
+ # latency win.
80
+ def split_long(balloon)
81
+ return [balloon] if balloon.length <= SENTENCE_AFTER
82
+
83
+ parts = balloon.split(SENTENCE_BOUNDARY).map(&:strip).reject(&:empty?)
84
+ return [balloon] if parts.empty?
85
+
86
+ parts.each_with_object([+""]) do |sentence, balloons|
87
+ if balloons.last.empty?
88
+ balloons.last << sentence
89
+ elsif balloons.last.length + sentence.length + 1 <= SENTENCE_AFTER
90
+ balloons.last << " #{sentence}"
91
+ else
92
+ balloons << +sentence
93
+ end
94
+ end
95
+ end
96
+
97
+ PARAGRAPH_BREAK = /\n\s*\n+/
98
+ FENCE = "```"
99
+ SENTENCE_BOUNDARY = /(?<=[.!?…])\s+(?=\S)/
100
+ private_constant :PARAGRAPH_BREAK, :FENCE, :SENTENCE_BOUNDARY
101
+ end
102
+ end
@@ -18,9 +18,11 @@ module Insika
18
18
  # BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
19
19
  # WS2; this file is only correct accounting.
20
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.
21
+ # Growth is bounded on the hot path AND swept: each `add` deletes the (id)'s
22
+ # previous day and previous month cell (an ACTIVE scope holds at most 4 keys),
23
+ # and `prune` the daily sweep on the tick — drops everything else: the cells
24
+ # of an id that went idle for more than one window and the alert flags, which
25
+ # the hot path never collects.
24
26
  class BudgetLedger
25
27
  SCOPE = "budget_counters"
26
28
  ALERT_SCOPE = "budget_alerts"
@@ -87,6 +89,25 @@ module Insika
87
89
  !@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
88
90
  end
89
91
 
92
+ # The GC of both scopes: drops every cell whose window is not the CURRENT
93
+ # one. `add`'s two deletes only reach the IMMEDIATELY previous day/month, so
94
+ # a scope that goes idle for two days leaves its counter behind forever, and
95
+ # the alert flags were never collected at all — unbounded row growth the WS8
96
+ # retention sweep does not reach (that one is age-based over CONTENT; these
97
+ # are counters with no timestamp). Every key of both scopes ENDS in its
98
+ # bucket, so one rule sweeps both. -> count of cells removed.
99
+ def prune(now: Time.now)
100
+ day = daily_bucket(now)
101
+ month = month_bucket(now)
102
+ @store.transaction do
103
+ [SCOPE, ALERT_SCOPE].sum do |scope|
104
+ stale = @store.list(scope).select { |k| past?(k.rpartition(":").last.to_i, day, month) }
105
+ stale.each { |k| @store.delete(scope, k) }
106
+ stale.size
107
+ end
108
+ end
109
+ end
110
+
90
111
  private
91
112
 
92
113
  # No tenant (single_tenant default) is a LITERAL "platform" cell, never a
@@ -95,6 +116,16 @@ module Insika
95
116
  [tenant || "platform", agent].join(":")
96
117
  end
97
118
 
119
+ # Is that bucket a window STRICTLY BEHIND the live one? The two kinds of
120
+ # bucket cannot collide — an epoch-day is a multiple of 86_400 (~1.7e9), a
121
+ # calendar month is year*12+month (~24e3) — so the magnitude tells them
122
+ # apart. STRICTLY behind, never "not the current one": a host whose clock
123
+ # runs minutes ahead writes tomorrow's cell around midnight, and a sweeper
124
+ # that deleted it would hand that tenant a fresh day of budget.
125
+ def past?(bucket, day, month)
126
+ bucket >= DAY ? bucket < day : bucket.positive? && bucket < month
127
+ end
128
+
98
129
  def bump(id, bucket, by)
99
130
  total = @store.get(SCOPE, key(id, bucket)).to_i + by
100
131
  @store.set(SCOPE, key(id, bucket), total)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # — per-AGENT cache-hit series (scope "cache_series"), for the
5
+ # Studio agent-detail plot. Sessions do not stamp their agent, so the
6
+ # per-session context trace cannot answer "cache-hit over time for THIS
7
+ # agent"; this capped list can. Entries are counts and a category name only —
8
+ # PII-free by construction. No retention hook: the cap bounds growth.
9
+ class CacheSeriesStore
10
+ SCOPE = "cache_series"
11
+ MAX_PER_AGENT = 200 # oldest dropped; one entry per turn, so 200 is a
12
+ # rolling window, not a leak path
13
+
14
+ def initialize(store:)
15
+ @store = store
16
+ end
17
+
18
+ # Appends a sanitized entry for the agent; caps. Rescues everything — the
19
+ # series never breaks the turn.
20
+ def record(agent:, entry:)
21
+ return if agent.to_s.empty?
22
+
23
+ list = (@store.get(SCOPE, agent.to_s) || []) + [sanitize(entry)]
24
+ @store.set(SCOPE, agent.to_s, list.last(MAX_PER_AGENT))
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ # -> [Hash] the agent's series, chronological. [] if none.
30
+ def for_agent(agent) = @store.get(SCOPE, agent.to_s) || []
31
+
32
+ private
33
+
34
+ def sanitize(entry)
35
+ e = entry.is_a?(Hash) ? entry : {}
36
+ {
37
+ "at" => (e[:at] || e["at"])&.to_s,
38
+ "turn" => int(e[:turn] || e["turn"]),
39
+ "hit_pct" => int_or_nil(e[:hit_pct] || e["hit_pct"]),
40
+ "cached_tokens" => int(e[:cached_tokens] || e["cached_tokens"]),
41
+ "prompt_tokens" => int(e[:prompt_tokens] || e["prompt_tokens"]),
42
+ "invalidation_reason" => (e[:invalidation_reason] || e["invalidation_reason"])&.to_s
43
+ }
44
+ end
45
+
46
+ def int(value) = Integer(value || 0)
47
+ def int_or_nil(value) = value.nil? ? nil : Integer(value)
48
+ end
49
+ end
@@ -26,7 +26,8 @@ module Insika
26
26
  BACKOFF_SECONDS = [1, 5].freeze
27
27
 
28
28
  def initialize(channels:, outbox:, session_store:, event_stream: nil,
29
- max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil)
29
+ max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil,
30
+ shadow_pairs: nil, criterion_sha: nil)
30
31
  @channels = channels
31
32
  @outbox = outbox
32
33
  @session_store = session_store
@@ -34,29 +35,66 @@ module Insika
34
35
  @max_attempts = max_attempts
35
36
  @backoff = Array(backoff)
36
37
  @sleeper = sleeper || method(:default_sleep)
38
+ @shadow_pairs = shadow_pairs
39
+ @criterion_sha = criterion_sha
37
40
  end
38
41
 
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:
42
+ # the pair store and the frozen criterion's sha. Both default to
43
+ # nil (parity a graph without them behaves exactly as today); the server
44
+ # root sets them at boot, after the criterion file has been loaded and
45
+ # refused-or-accepted (the graph itself reads no env and no file).
46
+ attr_writer :shadow_pairs, :criterion_sha
47
+
48
+ # Confirmed answer -> 0..N pending Deliveries, in order .
49
+ # A progressive channel splits on paragraphs (BalloonSplitter); everything
50
+ # else is the single whole-answer row.
51
+ # -> [] when there is nothing to send (the cheap exits):
41
52
  # · the turn did not come in through a channel,
42
53
  # · the channel is Shape A (answers on its own stream — no `deliver`),
43
54
  # · the answer is empty (a turn that died mid-message published nothing, and
44
55
  # half a sentence was never an answer),
56
+ # · the channel is in SHADOW mode: the answer is recorded as a
57
+ # pair and nothing is dispatched — zero outbox writes, ever (E1),
45
58
  # · 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
-
59
+ #
60
+ # `attachments` (evidence cards) ride the outbox payload as an
61
+ # ADDITIVE key on the LAST balloon — a Shape B channel that reads `payload`
62
+ # ignores it (JSON contract, additive); one that renders cards consumes it.
63
+ def record_balloons(task:, channel_id:, content:, progressive:, attachments: nil)
49
64
  channel = @channels&.find(channel_id)
50
- return nil unless channel.respond_to?(:deliver)
65
+ return [] unless channel.respond_to?(:deliver)
66
+ # Shadow records ONE pair for the whole answer — a balloon per paragraph
67
+ # would mint N pairs for one turn.
68
+ if shadow?(channel)
69
+ record_shadow(task, channel_id, content)
70
+ return []
71
+ end
72
+
73
+ return [] if content.to_s.strip.empty?
51
74
 
52
75
  to = recipient(channel, task.session_id)
53
- return nil if to.nil? || to.empty?
76
+ return [] if to.nil? || to.empty?
77
+
78
+ parts = progressive ? Insika::BalloonSplitter.split(content) : [content.to_s]
79
+ parts = parts.reject { |p| p.to_s.strip.empty? }
80
+ return [] if parts.empty?
81
+
82
+ multi = parts.size > 1
83
+ parts.each_with_index.map do |part, i|
84
+ last = i == parts.size - 1
85
+ create_pending(task, channel_id, part, to,
86
+ index: multi ? i : nil, final: multi ? last : nil,
87
+ attachments: last ? attachments : nil)
88
+ end
89
+ end
90
+
91
+ def shadow?(channel) = channel.respond_to?(:shadow?) && channel.shadow?
54
92
 
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
- )
93
+ # does this channel flush progressively? Duck-typed — a channel
94
+ # that does not answer `progressive?` is `:at_end`.
95
+ def progressive?(channel_id)
96
+ channel = @channels&.find(channel_id)
97
+ channel.respond_to?(:progressive?) && channel.progressive?
60
98
  end
61
99
 
62
100
  # Claim + POST + bounded retry. Safe to call twice: the second caller loses the
@@ -90,6 +128,73 @@ module Insika
90
128
 
91
129
  private
92
130
 
131
+ # One outbox row for a confirmed balloon. `index`/`final` ride the payload
132
+ # only when non-nil — a single-balloon progressive turn is indistinguishable
133
+ # from an `:at_end` one on the wire. `index` also lands on the RECORD, which
134
+ # is what the boot sweep orders by. `attachments` :
135
+ # validated for the outbox — malformed entries dropped, never a turn failure.
136
+ def create_pending(task, channel_id, content, to, index: nil, final: nil, attachments: nil)
137
+ payload = { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
138
+ "content" => content.to_s }
139
+ payload["index"] = index if index
140
+ payload["final"] = final unless final.nil?
141
+ atts = Insika::Evidence.valid_attachments(attachments)
142
+ payload["attachments"] = atts unless atts.empty?
143
+ @outbox.create(channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
144
+ payload: payload, index: index.to_i)
145
+ end
146
+
147
+ # Our half of the shadow pair (C3). One store upsert on the turn's terminal,
148
+ # then nil — `Executor#finalize_channel_delivery` returns without dispatching.
149
+ # The ordering rules: no event_id -> :shadow_unpairable (C1 makes this
150
+ # unreachable through the relay; a plugin channel could still get it wrong);
151
+ # no pair store wired -> the same event (fail-closed, nothing delivered);
152
+ # no recipient -> the same event (a pair keyed on an empty external_id can
153
+ # never meet the mirror's half).
154
+ def record_shadow(task, channel_id, content)
155
+ command = task.respond_to?(:command) ? task.command : nil
156
+ payload = command.is_a?(Hash) ? (command["payload"] || command[:payload] || {}) : {}
157
+ agent = payload["agent"] || payload[:agent]
158
+ message = payload["message"] || payload[:message]
159
+ event_id = Insika::Coercion.presence(payload["event_id"] || payload[:event_id])
160
+ if event_id.nil? || @shadow_pairs.nil?
161
+ emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
162
+ return nil
163
+ end
164
+
165
+ channel = @channels&.find(channel_id)
166
+ external_id = recipient(channel, task.session_id)
167
+ # The same empty-recipient guard the delivery path has: a pair keyed on an
168
+ # empty external_id can never meet the mirror's half (its digest differs),
169
+ # so the pair would sit :open forever. C1 makes this unreachable through
170
+ # the relay; a plugin channel could still get it wrong.
171
+ if Insika::Coercion.presence(external_id).nil?
172
+ emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
173
+ return nil
174
+ end
175
+
176
+ silent = content.to_s.strip.empty?
177
+ id = Insika::ShadowPairStore.key_for(channel: channel_id, external_id: external_id,
178
+ event_id: event_id)
179
+ @shadow_pairs.record_ours(id: id, channel: channel_id, agent: agent,
180
+ session_id: task.session_id, task_id: task.id,
181
+ event_id: event_id, inbound: message.to_s,
182
+ reply: content.to_s, criterion_sha: @criterion_sha)
183
+ emit_shadow(:shadow_recorded, channel_id, agent, id, silent: silent)
184
+ nil
185
+ end
186
+
187
+ # Metadata only: the stream reaches every subscriber and stays free of
188
+ # customer content, per the Studio's own emit_operator_action rule.
189
+ def emit_shadow(type, channel, agent, pair_id, silent:)
190
+ return unless @event_stream
191
+
192
+ data = { channel: channel.to_s, agent: agent, pair_id: pair_id }.compact
193
+ data[:silent] = silent unless silent.nil?
194
+ @event_stream.emit(Insika::Event.new(type: type, data: data,
195
+ meta: { at: Time.now.utc.iso8601 }))
196
+ end
197
+
93
198
  def attempt(delivery, channel)
94
199
  last_error = nil
95
200