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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # WS8 (phase 2): retention — the engine forgets what is old enough to forget.
7
+ # Age-based purge of the CONVERSATION footprint: sessions (and their
8
+ # per-session tool/context traces), terminal tasks (and their checkpoints),
9
+ # customer/tenant memory cells and outcome records. The knob is data, not
10
+ # code: `settings.retention_days` (Integer; nil/0 = OFF — parity, nothing is
11
+ # ever swept by default).
12
+ #
13
+ # Cadence: the Tick calls `run` every pass; an internal DAILY claim (the
14
+ # tick's window idiom — one key, a timestamp, 24 h) makes the actual sweep
15
+ # once a day, so the O(n) scans never ride the 60 s loop. A sweep that
16
+ # crashes propagates to the tick, which logs and keeps ticking.
17
+ class Retention
18
+ SCOPE = "retention"
19
+ KEY = "claim"
20
+ BUDGET_KEY = "budget_claim"
21
+ # the MEMORY TTL's own daily claim. Deliberately NOT the age-based
22
+ # KEY — memory TTLs sweep on their own knob (`memory_ttl_days`), gated by
23
+ # neither retention_days nor the age-based claim (D5).
24
+ MEMORY_TTL_KEY = "memory_ttl_claim"
25
+ WINDOW = 86_400 # one sweep per day, at most
26
+
27
+ TERMINAL = %w[completed failed cancelled].freeze
28
+
29
+ def initialize(session_store:, task_store:, checkpoint_store:,
30
+ memory_store:, outcome_store:, tool_trace_store: nil,
31
+ context_trace_store: nil, outbox_store: nil, shadow_pair_store: nil,
32
+ settings_store: nil, budget_ledger: nil, funnel_store: nil,
33
+ followup_store: nil, contact_store: nil, proposal_store: nil,
34
+ model_visible_trace_store: nil,
35
+ store:, window: WINDOW, now: nil, harvest_store: nil)
36
+ @session_store = session_store
37
+ @task_store = task_store
38
+ @checkpoint_store = checkpoint_store
39
+ @memory_store = memory_store
40
+ @outcome_store = outcome_store
41
+ @tool_trace_store = tool_trace_store
42
+ @context_trace_store = context_trace_store
43
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
44
+ @outbox_store = outbox_store
45
+ @shadow_pair_store = shadow_pair_store
46
+ @settings_store = settings_store
47
+ @budget_ledger = budget_ledger # WS2 counter GC; nil = nothing to sweep
48
+ @funnel_store = funnel_store # ; nil = nothing to sweep
49
+ @followup_store = followup_store # ; nil = nothing to sweep
50
+ @contact_store = contact_store # ; nil = nothing to sweep
51
+ @proposal_store = proposal_store # ; nil = nothing to sweep
52
+ @store = store
53
+ @window = window
54
+ @now = now # injectable for specs (a deterministic "today")
55
+ @harvest_store = harvest_store # ; nil = nothing to sweep
56
+ end
57
+
58
+ # the sweep reads the memory store's cells/records (specs seed
59
+ # facts through it).
60
+ attr_reader :memory_store
61
+
62
+ # -> { claimed: false } |
63
+ # { claimed: true, sessions:, tasks:, outcomes:, memory:, deliveries: }.
64
+ # Either shape may carry `budget_cells:` — the budget counter GC is NOT
65
+ # gated by retention_days (see #sweep_budget_cells). Either shape may carry
66
+ # `memory_ttl:` — the memory TTL sweep, gated by ITS OWN daily
67
+ # claim and knob, never by retention_days (see #sweep_memory_ttl).
68
+ def run
69
+ budget_cells = sweep_budget_cells
70
+ memory_ttl = sweep_memory_ttl
71
+ days = retention_days
72
+ unless days.to_i.positive? && claim_window
73
+ summary = { claimed: false }
74
+ summary[:budget_cells] = budget_cells if budget_cells
75
+ summary[:memory_ttl] = memory_ttl if memory_ttl
76
+ return summary
77
+ end
78
+
79
+ cutoff = now - (days.to_i * 86_400)
80
+ summary = { claimed: true, sessions: sweep_sessions(cutoff),
81
+ tasks: sweep_tasks(cutoff), outcomes: sweep_outcomes(cutoff),
82
+ memory: @memory_store.prune_older_than(cutoff),
83
+ deliveries: sweep_outbox(cutoff),
84
+ pairs: sweep_shadow_pairs(cutoff) }
85
+ summary[:funnel] = sweep_funnel(cutoff) if @funnel_store
86
+ # the follow-up footprint ages out with the rest — records
87
+ # and contact cells under the SAME retention_days gate (nil collaborator
88
+ # = nothing to sweep, base graph parity).
89
+ summary[:followups] = @followup_store.delete_older_than(cutoff) if @followup_store
90
+ summary[:contacts] = @contact_store.delete_older_than(cutoff) if @contact_store
91
+ # proposals are evidence OF a transcript — when the
92
+ # transcript dies, the proposal's excerpt is gone and the pending fact is
93
+ # stale. Pending AND terminal rows age out together; a proposal is
94
+ # re-derivable (D2), so pruning is never data loss. The session MARKERS
95
+ # are never pruned (the store's rule — the marker is the claim).
96
+ summary[:proposals] = @proposal_store.delete_older_than(cutoff) if @proposal_store
97
+ # candidates (pending AND terminal), log rows and
98
+ # snapshots are DERIVED data of transcripts (D11) — when the transcripts
99
+ # die, the candidates' excerpts are gone; they are re-derivable (D2), so
100
+ # pruning is never data loss. The session markers are never pruned.
101
+ summary[:harvest] = @harvest_store.delete_older_than(cutoff) if @harvest_store
102
+ summary[:budget_cells] = budget_cells if budget_cells
103
+ summary[:memory_ttl] = memory_ttl if memory_ttl
104
+ summary
105
+ end
106
+
107
+ private
108
+
109
+ # The BudgetLedger's expired cells (WS2). Deliberately OUTSIDE the
110
+ # retention_days gate, on its OWN daily claim: those rows are engine
111
+ # bookkeeping whose window already rolled over, not customer content, so a
112
+ # deployment that keeps its conversations forever (retention OFF — the
113
+ # default) must still not grow budget rows forever. -> count | nil (no
114
+ # ledger, or another worker holds today's claim).
115
+ def sweep_budget_cells
116
+ return nil unless @budget_ledger && claim(BUDGET_KEY)
117
+
118
+ @budget_ledger.prune(now: now)
119
+ end
120
+
121
+ def retention_days
122
+ return nil unless @settings_store
123
+
124
+ value = @settings_store.get["retention_days"]
125
+ value.to_s.empty? ? nil : Integer(value)
126
+ rescue ArgumentError, TypeError
127
+ nil # a non-numeric value reads as OFF — never a crash at sweep time
128
+ end
129
+
130
+ # the memory TTL sweep — TWO independent expiry clocks under
131
+ # ONE daily claim:
132
+ # 1. per-fact expires_at (prune_expired — past dates physically removed);
133
+ # 2. per-cell TTL by `memory_ttl_days` (age by the cell's updated_at).
134
+ # A fact with an explicit expires_at is EXCLUDED from the age-based pass
135
+ # (the explicit override owns that fact's life). Runs on its OWN claim and
136
+ # knob — a deployment with retention_days off still honors memory TTLs.
137
+ # -> Integer (removed) | nil (no knob, or another worker holds the claim).
138
+ def sweep_memory_ttl
139
+ ttl = memory_ttl_setting
140
+ return nil if ttl.nil?
141
+ return nil unless claim(MEMORY_TTL_KEY)
142
+
143
+ removed = @memory_store.prune_expired(now)
144
+ ttl_cutoffs(ttl).each do |scope, cutoff|
145
+ removed += @memory_store.prune_older_than(cutoff, scope: scope)
146
+ end
147
+ removed
148
+ end
149
+
150
+ # settings["memory_ttl_days"]: Integer | Hash{ "<tenant>" => days, "*" => days }.
151
+ # nil/empty/blank -> nil (OFF — parity). A non-numeric value -> nil (never
152
+ # a crash at sweep time, the retention_days rescue pattern).
153
+ def memory_ttl_setting
154
+ return nil unless @settings_store
155
+
156
+ raw = @settings_store.get["memory_ttl_days"]
157
+ case raw
158
+ when Integer then raw
159
+ when Hash
160
+ map = raw.each_with_object({}) do |(k, v), acc|
161
+ acc[k.to_s] = Integer(v.to_s)
162
+ rescue ArgumentError, TypeError
163
+ next
164
+ end
165
+ map.empty? ? nil : map
166
+ end
167
+ end
168
+
169
+ # -> [[scope, Time]] — one per existing cell with a resolved TTL. The
170
+ # setting is passed in (one settings-store read per sweep — the caller
171
+ # already resolved it).
172
+ def ttl_cutoffs(setting = memory_ttl_setting)
173
+ return [] unless setting
174
+
175
+ @memory_store.cells.filter_map do |cell|
176
+ days = cell_ttl(cell, setting)
177
+ next if days.nil? || days <= 0
178
+
179
+ [cell[:scope], now - days * 86_400]
180
+ end
181
+ end
182
+
183
+ # Customer cell "memory:acme:c-1" -> map["acme"]; tenant/bare cell
184
+ # "memory:acme" or "memory:c-123" -> map["acme"] / map["c-123"]; fallback
185
+ # map["*"]; an Integer setting -> every cell gets it.
186
+ def cell_ttl(cell, setting)
187
+ return setting if setting.is_a?(Integer)
188
+
189
+ key = cell[:tenant] || cell[:customer]
190
+ v = key && setting[key]
191
+ v ||= setting["*"]
192
+ v
193
+ end
194
+
195
+ # Sessions untouched past the cutoff, and their per-session traces.
196
+ def sweep_sessions(cutoff)
197
+ removed = 0
198
+ @session_store.each_id.each do |id|
199
+ session = @session_store.find(id)
200
+ next unless session && session.updated_at && session.updated_at < cutoff.iso8601
201
+
202
+ @tool_trace_store&.clear(id)
203
+ @context_trace_store&.clear(id)
204
+ @session_store.delete(id)
205
+ removed += 1
206
+ end
207
+ removed
208
+ end
209
+
210
+ # TERMINAL tasks untouched past the cutoff, and their checkpoints. A
211
+ # non-terminal task (queued/running) is never touched here — the Recovery
212
+ # sweep owns those lives. the model-visible traces are
213
+ # transcripts — they die next to their checkpoints.
214
+ def sweep_tasks(cutoff)
215
+ removed = 0
216
+ @task_store.each_id.each do |id|
217
+ task = @task_store.find(id)
218
+ next unless task && TERMINAL.include?(task.status.to_s)
219
+ next unless task.updated_at && task.updated_at < cutoff.iso8601
220
+
221
+ @checkpoint_store.purge(id)
222
+ @model_visible_trace_store&.purge(id)
223
+ @task_store.delete(id)
224
+ removed += 1
225
+ end
226
+ removed
227
+ end
228
+
229
+ def sweep_outcomes(cutoff)
230
+ @outcome_store ? @outcome_store.delete_older_than(cutoff) : 0
231
+ end
232
+
233
+ # The delivered/failed outbox records past the cutoff. Their `payload` is
234
+ # the answer the customer received — conversation content, so it ages out
235
+ # with the rest of the footprint instead of living in the store forever.
236
+ def sweep_outbox(cutoff)
237
+ @outbox_store ? @outbox_store.delete_older_than(cutoff) : 0
238
+ end
239
+
240
+ # Shadow pairs past the cutoff, TERMINAL statuses only
241
+ # (judged/incomplete) — an open/complete pair older than the window is
242
+ # still someone's unjudged evidence, exactly like the outbox's rule.
243
+ def sweep_shadow_pairs(cutoff)
244
+ @shadow_pair_store ? @shadow_pair_store.delete_older_than(cutoff) : 0
245
+ end
246
+
247
+ # the funnel DAY CELLS die with the outcomes they fold — same
248
+ # retention_days gate, same daily claim. Cursors/baselines live while their
249
+ # agent does. nil collaborator = nothing to sweep (base graph, parity).
250
+ def sweep_funnel(cutoff)
251
+ @funnel_store.delete_older_than(cutoff)
252
+ end
253
+
254
+ def claim_window = claim(KEY)
255
+
256
+ # The daily claim: one key, a timestamp, a 24 h window — the tick's
257
+ # claim_window idiom (a key per day would be a slow leak; the window is
258
+ # long enough that the store never grows). One key per SWEEP (the age-based
259
+ # one, the budget GC): they gate on different knobs, so a single shared key
260
+ # would let whichever ran first starve the other for a day.
261
+ def claim(key)
262
+ now_time = now
263
+ @store.transaction do
264
+ current = @store.get(SCOPE, key)
265
+ last = current && begin
266
+ Time.iso8601(current["claimed_at"].to_s)
267
+ rescue ArgumentError
268
+ nil
269
+ end
270
+ if last.nil? || (now_time - last) >= @window
271
+ @store.set(SCOPE, key, { "claimed_at" => now_time.iso8601 })
272
+ true
273
+ else
274
+ false
275
+ end
276
+ end
277
+ end
278
+
279
+ def now = @now || Time.now.utc
280
+ end
281
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WS4: intent routing as DATA. When `AgentProfile#routes` is present, the
5
+ # turn's message is classified into one of the configured routes with a cheap
6
+ # model BEFORE the agent chat is assembled. This class owns the PURE parts —
7
+ # normalizing the route config, generating the classifier prompt from the
8
+ # route descriptions, and parsing the model's answer back into a route — so
9
+ # they are testable without a provider. The ask itself and its usage are the
10
+ # Executor's (a pre-stage call counted in the turn's usage and trace).
11
+ #
12
+ # Config shape (string keys at the persistence boundary — the pack and the
13
+ # Studio store it like any other free-form hash):
14
+ #
15
+ # { "shopping" => "the customer wants to browse products",
16
+ # "order" => { "description" => "asks about an existing order",
17
+ # "delegate" => "order-agent" }, # hand the turn to that agent
18
+ # "human" => { "description" => "the customer is frustrated or asks for a person",
19
+ # "stuck" => true, "message" => "..." }, # end the turn :stuck (WS5)
20
+ # "default" => "shopping", # deterministic fallback
21
+ # "model" => "deepseek-v4-flash" } # the CHEAP classifier
22
+ #
23
+ # `default`/`model`/`provider` are reserved top-level keys; everything else is
24
+ # a route name (single token). A route value is a description String or a Hash
25
+ # with `description` + optional `delegate` (an existing agent the turn is
26
+ # handed to) / `stuck` (the turn ends with the WS5 stuck outcome) / `message`
27
+ # (the consumer-facing lead-in for a stuck route).
28
+ class Routing
29
+ RESERVED = %w[model provider default].freeze
30
+ # A name the classifier can actually answer with: one lowercase token.
31
+ NAME = /\A[a-z0-9][a-z0-9_-]*\z/
32
+ # The turn's usage fields the classifier call can contribute.
33
+ TOKEN_FIELDS = %i[input_tokens output_tokens cached_tokens cache_creation_tokens total_tokens].freeze
34
+
35
+ Entry = Data.define(:name, :description, :delegate, :stuck, :message)
36
+
37
+ class << self
38
+ # -> { entries: [Entry], default: String, model: String, provider: String }
39
+ # | nil (routes absent/empty = routing off). Raises ValidationError on a
40
+ # route name the classifier could never answer with (spaces/symbols) or on
41
+ # a config with zero routes — a routing config that silently routes nothing
42
+ # is a config error, not a quiet off.
43
+ def normalize(routes)
44
+ return nil unless routes.is_a?(Hash) && !routes.empty?
45
+
46
+ bad = routes.keys.map(&:to_s).reject { |k| RESERVED.include?(k) || k.match?(NAME) }
47
+ unless bad.empty?
48
+ raise Insika::ValidationError,
49
+ "route names may not contain spaces or symbols: #{bad.join(', ')}"
50
+ end
51
+
52
+ entries = routes.each_with_object([]) do |(name, value), acc|
53
+ next if RESERVED.include?(name.to_s)
54
+
55
+ cfg = value.is_a?(Hash) ? stringify(value) : { "description" => value.to_s }
56
+ acc << Entry.new(name: name.to_s, description: cfg["description"].to_s,
57
+ delegate: cfg["delegate"].to_s, stuck: cfg["stuck"] == true,
58
+ message: cfg["message"].to_s)
59
+ end
60
+ raise Insika::ValidationError, "routes must define at least one route" if entries.empty?
61
+
62
+ default = routes["default"].to_s
63
+ default = entries.first.name if default.empty?
64
+ unless entries.any? { |e| e.name == default }
65
+ raise Insika::ValidationError,
66
+ "default route '#{default}' is not a configured route"
67
+ end
68
+ { entries: entries, default: default,
69
+ model: routes["model"].to_s, provider: routes["provider"].to_s }
70
+ end
71
+
72
+ # The classifier's instructions, auto-generated from the route descriptions
73
+ # (config-over-code: no per-route prompt file).
74
+ def classifier_prompt(meta)
75
+ lines = meta[:entries].map do |e|
76
+ "-#{e.name}: #{e.description.empty? ? e.name : e.description}"
77
+ end
78
+ <<~PROMPT
79
+ Classify the customer's message into exactly one of these intents.
80
+ Reply with ONLY the intent name — nothing else.
81
+
82
+ #{lines.join("\n")}
83
+ PROMPT
84
+ end
85
+
86
+ # -> Symbol: the route the model named; the DEFAULT when it named nothing
87
+ # usable (prose, punctuation, an unknown name, empty). Deterministic by
88
+ # construction — a chatty or confused classifier can never invent a route.
89
+ def parse(text, meta)
90
+ token = text.to_s.strip.split(/\s+/).first.to_s.downcase
91
+ .delete_suffix(".").delete_suffix(",")
92
+ names = meta[:entries].map(&:name)
93
+ (names.include?(token) ? token : meta[:default]).to_sym
94
+ end
95
+ end
96
+
97
+ def self.stringify(hash)
98
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
99
+ end
100
+ end
101
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Insika
4
4
  module Safety
5
- # Per-agent guardrail configuration (RFC-0009 §3.3 / D6), read from
5
+ # Per-agent guardrail configuration, read from
6
6
  # `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
7
7
  # gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
8
8
  #
@@ -16,9 +16,20 @@ module Insika
16
16
  # moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
17
17
  # strictness: "low"|"medium"|"high", # which input categories fire (default medium)
18
18
  # responses: { <category> => "<safe reply>", ... } # per-agent override, see below
19
+ # corpora: { "languages" => ["en"], "extra" => { "abuse" => ["/\\bdupa\\b/i"] } }
19
20
  # }
20
21
  #
21
- # `responses` is the CONFIGURATION-OVER-CONVENTION knob (RFC-0009 §7). The engine
22
+ # `corpora` is the removability knob for the shipped
23
+ # pt-BR corpus: `languages` filters the shipped families (nil = all,
24
+ # [] = none, ["en"] = the EN-only corpus — dropping the pt-BR input
25
+ # heuristics AND the CPF/CNPJ output redaction, a documented consequence),
26
+ # `extra` adds source-string patterns per family. Absent = the full
27
+ # shipped default, byte-for-byte today's behavior (parity). Validation
28
+ # fails loud (Insika::ValidationError naming the value): the doctor's
29
+ # guardrail-corpora check compiles every declaration, so a typo'd language
30
+ # surfaces at boot — `insika doctor` exits non-zero — never mid-turn.
31
+ #
32
+ # `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
22
33
  # ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
23
34
  # OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
24
35
  # overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
@@ -37,14 +48,18 @@ module Insika
37
48
 
38
49
  DEFAULT_STRICTNESS = :medium
39
50
 
40
- attr_reader :input, :output, :moderator, :strictness, :responses
51
+ attr_reader :input, :output, :moderator, :strictness, :responses, :corpora
41
52
 
42
- def initialize(input:, output:, moderator:, strictness:, responses: {})
53
+ def initialize(input:, output:, moderator:, strictness:, responses: {}, corpora: nil)
43
54
  @input = input
44
55
  @output = output
45
56
  @moderator = moderator
46
57
  @strictness = strictness
47
58
  @responses = responses # { "category" => "safe reply" }, agent override map
59
+ @corpora = corpora # { "languages" => [...]?, "extra" => {...}? } | nil (nil = the shipped default)
60
+ # Built ONCE at construction: the compiled corpus the whole turn reads.
61
+ @corpus = Corpus.compile(languages: corpora && corpora["languages"],
62
+ extra: (corpora && corpora["extra"]) || {})
48
63
  end
49
64
 
50
65
  # Builds a Config from a profile. A nil/empty `guardrails` -> the
@@ -58,10 +73,22 @@ module Insika
58
73
  output: bool(h.fetch(:output, true)),
59
74
  moderator: presence(h[:moderator]),
60
75
  strictness: normalize_strictness(h[:strictness]),
61
- responses: normalize_responses(h[:responses])
76
+ responses: normalize_responses(h[:responses]),
77
+ corpora: normalize_corpora(h[:corpora])
62
78
  )
63
79
  end
64
80
 
81
+ # The compiled corpus for this agent — never nil: absent
82
+ # corpora -> the full shipped default.
83
+ def corpus = @corpus
84
+
85
+ # The resolved corpus languages (for the doctor's enumeration, C3):
86
+ # nil corpora (or nil languages) = ALL shipped languages.
87
+ def corpus_languages
88
+ langs = @corpora && @corpora["languages"]
89
+ langs.nil? ? Corpus::DEFAULTS.keys : langs
90
+ end
91
+
65
92
  # Input categories the deterministic scan should run, per strictness.
66
93
  def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
67
94
 
@@ -103,7 +130,20 @@ module Insika
103
130
  end
104
131
  end
105
132
 
106
- private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
133
+ # `corpora` -> { "languages" => ...?, "extra" => ...? } with STRING keys |
134
+ # nil when absent/empty. Validation of the values happens in
135
+ # Corpus.compile (from_hash fails loud with the value named).
136
+ def self.normalize_corpora(v)
137
+ return nil unless v.is_a?(Hash)
138
+
139
+ h = v.each_with_object({}) { |(k, val), acc| acc[k.to_s] = val }
140
+ return nil if h["languages"].nil? && h["extra"].nil?
141
+
142
+ { "languages" => h["languages"], "extra" => h["extra"] }
143
+ end
144
+
145
+ private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses,
146
+ :normalize_corpora
107
147
  end
108
148
  end
109
149
  end