insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # The report destination: one record per run, no versioning, the listing
8
+ # IS the history. A store, not a CMS.
9
+ #
10
+ # The tenant is a BINDING of the row — inherited from the agent that saved
11
+ # the artifact (the tool reads it from the turn context, never from the
12
+ # model), so a purge is a tenant-prefix scan and store A's report can never
13
+ # appear in, or be linked from, store B.
14
+ #
15
+ # Record key: "<tenant>:<agent>:<id>" — per-tenant / per-agent scans are
16
+ # prefixes; the id is the LAST segment, so a find is a suffix match (the
17
+ # followup_store.rb idiom — there is no stable prefix for an id alone).
18
+ # Blank tenant -> the literal "platform" (the outcome_store.rb rule, so the
19
+ # purge prefix scans line up).
20
+ class ArtifactStore
21
+ SCOPE = "artifacts"
22
+
23
+ # The mime allowlist — a page, not an attachment: no binaries, no uploads.
24
+ MIMES = %w[text/html text/markdown image/svg+xml].freeze
25
+
26
+ # The size cap on `content` (INSIKA_ARTIFACT_MAX_BYTES; the audio-message
27
+ # precedent is 1 MB — an artifact is a page, not an attachment).
28
+ DEFAULT_MAX_BYTES = 1_000_000
29
+ TITLE_MAX = 200
30
+
31
+ Record = Data.define(:id, :tenant, :agent, :task_id, :title, :mime,
32
+ :content, :created_at)
33
+
34
+ def initialize(store:)
35
+ @store = store
36
+ end
37
+
38
+ # -> Record. Validates the mime allowlist, a non-empty title (<= 200
39
+ # chars) and content within `max_bytes` — ValidationError otherwise (the
40
+ # tool returns it to the model as `{ error: }`).
41
+ def create(tenant:, agent:, task_id:, title:, mime:, content:,
42
+ id: SecureRandom.uuid, now: Time.now.utc, max_bytes: DEFAULT_MAX_BYTES)
43
+ mime = "text/html" if mime.to_s.empty?
44
+ raise Insika::ValidationError, "mime must be one of #{MIMES.join(', ')}, got #{mime.inspect}" unless MIMES.include?(mime.to_s)
45
+
46
+ title = title.to_s
47
+ raise Insika::ValidationError, "title is required (1..#{TITLE_MAX} chars)" if title.strip.empty? || title.length > TITLE_MAX
48
+
49
+ content = content.to_s
50
+ raise Insika::ValidationError, "content is required" if content.empty?
51
+ raise Insika::ValidationError, "content exceeds #{max_bytes} bytes (#{content.bytesize})" if content.bytesize > max_bytes
52
+
53
+ record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
54
+ "task_id" => task_id.to_s, "title" => title, "mime" => mime.to_s,
55
+ "content" => content, "created_at" => now.iso8601 }
56
+ @store.set(SCOPE, key(record), record)
57
+ to_record(record)
58
+ end
59
+
60
+ # -> Record | nil. Suffix match over the scope's keys (no stable prefix
61
+ # for an id alone — the followup_store.rb idiom).
62
+ def find(id)
63
+ key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
64
+ key && to_record(@store.get(SCOPE, key))
65
+ end
66
+
67
+ # -> [Record] — one agent's artifacts, newest first (the listing IS the
68
+ # history; the Studio tab's read).
69
+ def for_agent(tenant:, agent:)
70
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
71
+ @store.list(SCOPE).filter_map do |k|
72
+ next unless k.start_with?(prefix)
73
+
74
+ to_record(@store.get(SCOPE, k))
75
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
76
+ end
77
+
78
+ # -> [Record] — EVERY agent's artifacts for the tenant, newest first (same
79
+ # prefix scan as #purge, but reads instead of deletes). Needs the CALLER
80
+ # to know the right tenant string — see #all below for the Studio, which
81
+ # doesn't.
82
+ def for_tenant(tenant:)
83
+ prefix = "#{tenant_id(tenant)}:"
84
+ @store.list(SCOPE).filter_map do |k|
85
+ next unless k.start_with?(prefix)
86
+
87
+ to_record(@store.get(SCOPE, k))
88
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
89
+ end
90
+
91
+ # -> [Record] — every artifact in the store, optionally narrowed to one
92
+ # agent, with NO tenant guess. For the Studio only: it is already
93
+ # operator-only (sees every tenant's agents), and unlike #for_agent/
94
+ # #for_tenant (hot, tenant-scoped runtime paths that a real multi-tenant
95
+ # caller uses because it KNOWS its own tenant) the Studio often does not
96
+ # — `save_artifact` binds tenant from the turn (`agent` when the Playground
97
+ # dispatched it, `"platform"` for a plain single-tenant API turn), so a
98
+ # page guessing one fixed string is wrong for the other half of the time.
99
+ # Filtering by the record's own `agent` field sidesteps the guess entirely.
100
+ def all(agent: nil)
101
+ @store.list(SCOPE).filter_map do |k|
102
+ record = to_record(@store.get(SCOPE, k))
103
+ next if record.nil?
104
+ next if agent && record.agent != agent.to_s
105
+
106
+ record
107
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
108
+ end
109
+
110
+ # -> bool (did it exist?).
111
+ def delete(id)
112
+ key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
113
+ key ? @store.delete(SCOPE, key) : false
114
+ end
115
+
116
+ # -> count removed. The tenant-erasure reach — one tenant's artifacts die
117
+ # with it, never a neighbour's.
118
+ def purge(tenant:)
119
+ prefix = "#{tenant_id(tenant)}:"
120
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
121
+ keys.each { |k| @store.delete(SCOPE, k) }
122
+ keys.size
123
+ end
124
+
125
+ # -> count removed. The retention knob's reach (`artifact_ttl_days`) —
126
+ # the guarantee that PII inside a report expires even though no reader
127
+ # can see inside the opaque HTML.
128
+ def delete_older_than(time)
129
+ cutoff = time.utc.iso8601
130
+ removed = 0
131
+ @store.list(SCOPE).each do |k|
132
+ record = @store.get(SCOPE, k)
133
+ next unless record && record["created_at"].to_s < cutoff
134
+
135
+ @store.delete(SCOPE, k)
136
+ removed += 1
137
+ end
138
+ removed
139
+ end
140
+
141
+ private
142
+
143
+ def key(record)
144
+ "#{record['tenant']}:#{record['agent']}:#{record['id']}"
145
+ end
146
+
147
+ def tenant_id(tenant)
148
+ t = tenant.to_s
149
+ t.empty? ? "platform" : t
150
+ end
151
+
152
+ def to_record(rec)
153
+ return nil if rec.nil?
154
+
155
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
156
+ task_id: rec["task_id"], title: rec["title"], mime: rec["mime"],
157
+ content: rec["content"], created_at: rec["created_at"])
158
+ end
159
+ end
160
+ end
@@ -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
@@ -17,7 +17,7 @@ module Insika
17
17
  # party with outages, so "keep trying" is a real requirement and "keep trying
18
18
  # forever" is a real outage of ours.
19
19
  #
20
- # It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
20
+ # It is NOT a job queue: no priorities, no fan-out. The moment it
21
21
  # grows one, the thing to do is take a real queue, not to finish building this.
22
22
  class ChannelDelivery
23
23
  MAX_ATTEMPTS = 3
@@ -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