insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -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
@@ -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,166 @@
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 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.
26
+ class BudgetLedger
27
+ SCOPE = "budget_counters"
28
+ ALERT_SCOPE = "budget_alerts"
29
+ DAY = 86_400
30
+
31
+ def initialize(store:)
32
+ @store = store
33
+ end
34
+
35
+ # Adds `by` across both windows; -> { daily:, monthly: } the NEW totals
36
+ # for (tenant, agent). Atomic per call: one transaction, both bumps.
37
+ def add(tenant:, agent:, by:, now: Time.now)
38
+ id = cell_id(tenant, agent)
39
+ @store.transaction do
40
+ daily = bump(id, daily_bucket(now), by)
41
+ monthly = bump(id, month_bucket(now), by)
42
+ @store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell
43
+ @store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell
44
+ { daily: daily, monthly: monthly }
45
+ end
46
+ end
47
+
48
+ # -> { daily:, monthly: } current totals for (tenant, agent). Purely
49
+ # read; an expired window reads as 0 (rolls over at the boundary).
50
+ def current(tenant:, agent:, now: Time.now)
51
+ id = cell_id(tenant, agent)
52
+ { daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i,
53
+ monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i }
54
+ end
55
+
56
+ # Seconds until the window's bucket rolls over (the retry_after the
57
+ # enforcement quotes when a hard budget refuses a turn). Both windows are
58
+ # UTC-aligned (the daily via the epoch, the monthly via UTC components) so a
59
+ # non-UTC host never quotes a negative or local-midnight reset.
60
+ def reset_in(window, now: Time.now)
61
+ case window
62
+ when :daily then DAY - (now.to_i % DAY)
63
+ when :monthly then (next_utc_month_start(now) - now).to_i
64
+ end
65
+ end
66
+
67
+ # "1× per window" alert markers (the soft enforcement's event): a flag per
68
+ # (id, window, level, bucket) so a budget that stays over the threshold
69
+ # cannot spam one event per turn. `level:` separates DISTINCT triggers in
70
+ # the same window (WS2): the `alert_at` crossing and the real soft-cap
71
+ # crossing each warn once — the cap event must not be swallowed by the
72
+ # 80% marker having fired earlier. Marked/read in the same transaction
73
+ # discipline. -> bool: had the window already been marked?
74
+ def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now)
75
+ id = cell_id(tenant, agent)
76
+ flag = alert_key(id, window, now, level)
77
+ @store.transaction do
78
+ # `next`, NOT `return`: a non-local return from inside the block skips
79
+ # the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn
80
+ # over a threshold then locks the whole backend (WS2).
81
+ next true unless @store.get(ALERT_SCOPE, flag).nil?
82
+
83
+ @store.set(ALERT_SCOPE, flag, 1)
84
+ false
85
+ end
86
+ end
87
+
88
+ def alerted?(tenant:, agent:, window:, level: nil, now: Time.now)
89
+ !@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
90
+ end
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
+
111
+ private
112
+
113
+ # No tenant (single_tenant default) is a LITERAL "platform" cell, never a
114
+ # null-key collision with some other scope.
115
+ def cell_id(tenant, agent)
116
+ [tenant || "platform", agent].join(":")
117
+ end
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
+
129
+ def bump(id, bucket, by)
130
+ total = @store.get(SCOPE, key(id, bucket)).to_i + by
131
+ @store.set(SCOPE, key(id, bucket), total)
132
+ total
133
+ end
134
+
135
+ # the UTC calendar day's start (epoch is aligned to midnight UTC).
136
+ def daily_bucket(now)
137
+ (now.to_i / DAY) * DAY
138
+ end
139
+
140
+ # the UTC calendar month as one integer (2026-08 -> 24296). UTC, not local:
141
+ # the month boundary must agree with the daily epoch-day boundary on a
142
+ # non-UTC host (WS2), or the cap resets at a different moment than the day.
143
+ def month_bucket(now)
144
+ u = now.utc
145
+ u.year * 12 + u.month
146
+ end
147
+
148
+ # Midnight UTC of the 1st of the window's NEXT month — December-safe
149
+ # (Time.utc(y, 13, 1) raises; y+1/1 is the calendar answer).
150
+ def next_utc_month_start(now)
151
+ u = now.utc
152
+ u.month == 12 ? Time.utc(u.year + 1, 1, 1) : Time.utc(u.year, u.month + 1, 1)
153
+ end
154
+
155
+ def key(id, bucket)
156
+ "#{id}:#{bucket}"
157
+ end
158
+
159
+ # One alert flag per (id, window, level, calendar bucket): daily cells are
160
+ # keyed by day, monthly by (year*12+month) — a flag dies with its window.
161
+ def alert_key(id, window, now, level = nil)
162
+ bucket = window == :monthly ? month_bucket(now) : daily_bucket(now)
163
+ level ? "#{id}:#{window}:#{level}:#{bucket}" : "#{id}:#{window}:#{bucket}"
164
+ end
165
+ end
166
+ end
@@ -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
@@ -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:
@@ -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
- # half a sentence was never an answer — P19),
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
 
@@ -111,7 +216,7 @@ module Insika
111
216
  end
112
217
 
113
218
  # 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
219
+ # when the channel minted it; the channel's own id parser is the fallback
115
220
  # for a session created before those vars existed.
116
221
  def recipient(channel, session_id)
117
222
  session = session_id && @session_store.find(session_id)
@@ -130,14 +235,17 @@ module Insika
130
235
  def emit(delivery)
131
236
  return unless @event_stream
132
237
 
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
- ))
238
+ data = { channel: delivery.channel, outbox_id: delivery.id,
239
+ status: delivery.status.to_s, attempts: delivery.attempts,
240
+ error: delivery.last_error }
241
+ meta = { task_id: delivery.task_id, session_id: delivery.session_id,
242
+ at: Time.now.utc.iso8601 }
243
+ # :delivery_failed is the ALERT face of a failed delivery (WS6) — emitted
244
+ # alongside :channel_delivered so the delivery audit stream is unchanged.
245
+ @event_stream.emit(Insika::Event.new(type: :channel_delivered, data: data, meta: meta))
246
+ if delivery.status == :failed
247
+ @event_stream.emit(Insika::Event.new(type: :delivery_failed, data: data, meta: meta))
248
+ end
141
249
  end
142
250
 
143
251
  # 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
  #