insika 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -23,7 +23,14 @@ module Insika
23
23
  KEY_PREFIX = "session:"
24
24
 
25
25
  Session = Data.define(:id, :messages, :vars, :memory_refs,
26
- :created_at, :updated_at)
26
+ :created_at, :updated_at, :briefing, :evidence) do
27
+ # Trailing members with defaults: an old record without the "briefing" /
28
+ # "evidence" keys reads as empty/nil without a migration.
29
+ def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
30
+ briefing: nil, evidence: nil)
31
+ super
32
+ end
33
+ end
27
34
 
28
35
  # store: any Insika::Store (Memory, SQLite, ...) — injected by the
29
36
  # composition root (config/wiring.rb). SessionStore does not know the
@@ -44,6 +51,8 @@ module Insika
44
51
  "messages" => [],
45
52
  "vars" => deep_stringify(vars),
46
53
  "memory_refs" => [],
54
+ "briefing" => { "fields" => {}, "next_step" => nil },
55
+ "evidence" => { "ids" => [], "ungrounded" => 0 },
47
56
  "created_at" => now,
48
57
  "updated_at" => now
49
58
  }
@@ -89,6 +98,58 @@ module Insika
89
98
  to_session(record)
90
99
  end
91
100
 
101
+ # appends this turn's evidence (ids + ungrounded delta) to the
102
+ # session record. RMW like append_messages — the SessionActor serializes
103
+ # same-session turns; the copy is in the method comment.
104
+ def append_evidence(id, ids:, ungrounded:)
105
+ record = fetch!(id)
106
+ ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
107
+ fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
108
+ ev["ids"] = fresh
109
+ ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
110
+ record["updated_at"] = timestamp
111
+ @store.set(SCOPE, key_for(id), record)
112
+ to_session(record)
113
+ end
114
+
115
+ # -> Session. Upsert ONE briefing field. The pack owns the schema; the
116
+ # engine validates nothing about field NAMES here (the tools do, at the
117
+ # write edge). value is a String (anything else -> to_s); a BLANK value
118
+ # (after strip) REMOVES the key — absence means "not yet asked" (
119
+ # D4). NotFoundError if the session does not exist.
120
+ #
121
+ # CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
122
+ # append_messages — but the SessionActor argument does NOT apply here. A
123
+ # briefing writer is a system tool, never enveloped, and with
124
+ # tool_concurrency > 1 the gem runs each call in its OWN fiber — outside the
125
+ # actor's per-session turn serialization. The RMW is still safe, for a
126
+ # different reason: nothing in the read/mutate/set path suspends. Store
127
+ # get/set are synchronous (the SQLite write semaphore is a non-yielding fast
128
+ # path when free), and a fiber only switches at a scheduler suspension point
129
+ # — so no other writer can interleave mid-RMW (measured: N concurrent
130
+ # writers lose nothing). It holds ONLY while that path never suspends; an
131
+ # async store (a real yield in get/set) would need a lock or CAS.
132
+ def update_briefing(id, field:, value:)
133
+ record = fetch!(id)
134
+ briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
135
+ value = Coercion.presence(Coercion.utf8(value.to_s))
136
+ value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
137
+ record["updated_at"] = timestamp
138
+ @store.set(SCOPE, key_for(id), record)
139
+ to_session(record)
140
+ end
141
+
142
+ # -> Session. Upsert the agreed next step; a blank text clears to nil
143
+ # NotFoundError if absent.
144
+ def set_next_step(id, text:)
145
+ record = fetch!(id)
146
+ briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
147
+ briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
148
+ record["updated_at"] = timestamp
149
+ @store.set(SCOPE, key_for(id), record)
150
+ to_session(record)
151
+ end
152
+
92
153
  # -> bool (delegates to the backend: false for a nonexistent id)
93
154
  def delete(id)
94
155
  @store.delete(SCOPE, key_for(id))
@@ -126,7 +187,9 @@ module Insika
126
187
  vars: record["vars"],
127
188
  memory_refs: record["memory_refs"],
128
189
  created_at: record["created_at"],
129
- updated_at: record["updated_at"]
190
+ updated_at: record["updated_at"],
191
+ briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
192
+ evidence: record["evidence"]
130
193
  )
131
194
  end
132
195
 
@@ -31,6 +31,16 @@ module Insika
31
31
  "turn_timeout" => 120,
32
32
  "tool_timeout" => 30,
33
33
  "compaction" => { "enabled" => false, "keep_last" => 20 },
34
+ # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
35
+ # tick's Retention sweep purges sessions (+traces), terminal tasks
36
+ # (+checkpoints), memory cells and outcomes older than this. nil/0 =
37
+ # OFF (parity — nothing is ever swept by default).
38
+ "retention_days" => nil,
39
+ # memory TTL. Integer = default for every cell; Hash =
40
+ # per-tenant { "<tenant>" => days, "*" => days } (ops-authored). nil = OFF.
41
+ # Swept by the Retention tick on its OWN daily claim, NOT gated by
42
+ # retention_days (D5). Additive key — reads overlay DEFAULTS.
43
+ "memory_ttl_days" => nil,
34
44
  # LLM config v2. Platform-wide model layer, resolved by the
35
45
  # ModelResolver under an agent that pins no model of its own:
36
46
  # default_model/default_provider -> the platform default (Chat > Agent > HERE)
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Insika
7
+ # C2 — one durable record per mirrored exchange (shadow mode),
8
+ # written by TWO INDEPENDENT HALVES: ours at the turn's terminal, the
9
+ # incumbent's at the mirror. Both land on the same key — a digest of
10
+ # (channel, external_id, event_id), deterministic and order-free — so the two
11
+ # writers converge without an index and without ordering assumptions.
12
+ #
13
+ # It stores and it counts; it does not judge, does not fold a verdict, does
14
+ # not know what the criterion says.
15
+ #
16
+ # Status transitions (never backwards):
17
+ #
18
+ # ┌─ record_incumbent ─┐
19
+ # (nothing) ────┤ ├─▶ open ─▶ complete ─▶ judged
20
+ # └─ record_ours ─────┘ └─▶ silent (never judged)
21
+ # └─▶ incomplete (expire)
22
+ class ShadowPairStore
23
+ SCOPE = "shadow_pairs"
24
+ KEY_PREFIX = "pair:"
25
+
26
+ STATUSES = %i[open complete silent judged incomplete].freeze
27
+
28
+ Pair = Data.define(
29
+ :id, :channel, :agent, :session_id, :task_id, :event_id,
30
+ :inbound, :incumbent_reply, :insika_reply,
31
+ :status, :verdict, :criterion_sha, :created_at, :updated_at
32
+ ) do
33
+ def complete? = %i[complete silent].include?(status)
34
+ def judged? = status == :judged
35
+ def outcome = verdict && verdict["outcome"]
36
+ def human_assisted? = verdict && verdict["vs"] == "human-assisted"
37
+ end
38
+
39
+ def initialize(store:)
40
+ @store = store
41
+ end
42
+
43
+ # The correlation key BOTH writers compute independently. SHA-256 hex of
44
+ # "<channel>\0<external_id>\0<event_id>", truncated to 32 — deterministic,
45
+ # order-free, and it keeps a phone number out of the store's key space.
46
+ def self.key_for(channel:, external_id:, event_id:)
47
+ Digest::SHA256.hexdigest("#{channel}\0#{external_id}\0#{event_id}")[0, 32]
48
+ end
49
+
50
+ # Our half. Upsert: creates the record or fills our fields on the incumbent's.
51
+ # `reply` may be "" — a turn that published nothing (halt_when, an out-of-band
52
+ # tool) is recorded as :silent rather than left invisible. -> Pair
53
+ def record_ours(id:, channel:, agent:, session_id:, task_id:, event_id:,
54
+ inbound:, reply:, criterion_sha:)
55
+ upsert(id) do |record, created|
56
+ record["channel"] = channel.to_s
57
+ record["event_id"] = event_id.to_s
58
+ record["agent"] = agent
59
+ record["session_id"] = session_id&.to_s
60
+ record["task_id"] = task_id&.to_s
61
+ record["inbound"] = inbound.to_s
62
+ record["insika_reply"] = reply.to_s
63
+ record["criterion_sha"] = criterion_sha
64
+ created
65
+ end
66
+ end
67
+
68
+ # The incumbent's half (the mirror contract). Same upsert shape; the fields
69
+ # this half owns are the reply and, on first write, the timestamp the mirror
70
+ # reports. Never overwrites our half's fields. First-write-wins is enforced
71
+ # HERE, inside the transaction: the customer received ONE reply, and two
72
+ # concurrent mirror retries must not let the second rewrite the evidence.
73
+ # -> Pair
74
+ def record_incumbent(id:, channel:, event_id:, external_id:, reply:, at: nil)
75
+ upsert(id, at: at) do |record, created|
76
+ record["channel"] = channel.to_s
77
+ record["event_id"] = event_id.to_s
78
+ record["incumbent_reply"] = reply.to_s if record["incumbent_reply"].nil?
79
+ created
80
+ end
81
+ end
82
+
83
+ # -> Pair | nil
84
+ def find(id)
85
+ record = @store.get(SCOPE, key_for(id))
86
+ record && to_pair(record)
87
+ end
88
+
89
+ # Lazy scan over a SNAPSHOT of the keys (deleting under a live enumeration
90
+ # would skip records — the same rule OutboxStore applies).
91
+ def each(&block)
92
+ return enum_for(:each) unless block_given?
93
+
94
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
95
+ record = @store.get(SCOPE, key)
96
+ yield to_pair(record) if record
97
+ end
98
+ end
99
+
100
+ # -> [Pair] created at or after `time`.
101
+ def since(time)
102
+ cutoff = time.utc.iso8601
103
+ each.select { |p| p.created_at.to_s >= cutoff }
104
+ end
105
+
106
+ # -> [Pair] status :complete, oldest first — the judging queue. `silent`
107
+ # pairs are NEVER here: finding that pairwise is
108
+ # systematically unfair to a tool that delivers out of band is not
109
+ # something to average away.
110
+ def unjudged(limit: nil, agent: nil)
111
+ pairs = each.select { |p| p.status == :complete }
112
+ .sort_by { |p| p.created_at.to_s }
113
+ pairs = pairs.select { |p| p.agent.to_s == agent.to_s } if agent
114
+ limit ? pairs.first(limit.to_i) : pairs
115
+ end
116
+
117
+ # -> { open:, complete:, silent:, judged:, incomplete: }
118
+ def counts(since: nil)
119
+ pairs = since ? self.since(since) : each.to_a
120
+ STATUSES.to_h { |s| [s, pairs.count { |p| p.status == s }] }
121
+ end
122
+
123
+ # The panel's Verdict as data. status -> :judged. -> Pair
124
+ def record_verdict(id, verdict:)
125
+ @store.transaction do
126
+ key = key_for(id)
127
+ record = @store.get(SCOPE, key)
128
+ raise Insika::NotFoundError, "shadow pair not found: #{id}" unless record
129
+
130
+ record["verdict"] = Coercion.deep_stringify(verdict)
131
+ record["status"] = "judged"
132
+ record["updated_at"] = timestamp
133
+ @store.set(SCOPE, key, record)
134
+ to_pair(record)
135
+ end
136
+ end
137
+
138
+ # An `open` pair older than the cutoff will never complete. -> count moved.
139
+ # `complete`/`silent`/`judged` are never touched. Update-style only: a pair
140
+ # deleted between the scan and the write (retention, LGPD purge) is left
141
+ # deleted — an upsert here would resurrect it as a ghost :incomplete record
142
+ # carrying none of its fields.
143
+ def expire(older_than:)
144
+ cutoff = older_than.utc.iso8601
145
+ moved = 0
146
+ each.select { |p| p.status == :open && p.created_at.to_s < cutoff }.each do |pair|
147
+ @store.transaction do
148
+ key = key_for(pair.id)
149
+ record = @store.get(SCOPE, key)
150
+ next unless record && record["status"] == "open"
151
+
152
+ record["status"] = "incomplete"
153
+ record["updated_at"] = timestamp
154
+ @store.set(SCOPE, key, record)
155
+ moved += 1
156
+ end
157
+ end
158
+ moved
159
+ end
160
+
161
+ # -> Integer. Keys only, no record materialization — a count of pairs must
162
+ # not pay for customer text (doctor's shadow-off check, the Studio).
163
+ def size = @store.list(SCOPE, KEY_PREFIX).length
164
+
165
+ # LGPD / retention (C9): drops every pair of these sessions, whatever its
166
+ # status — the pair holds the customer's own words. -> count removed.
167
+ def purge_sessions(session_ids)
168
+ wanted = Array(session_ids).map(&:to_s)
169
+ return 0 if wanted.empty?
170
+
171
+ doomed = each.select { |p| wanted.include?(p.session_id.to_s) }
172
+ doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
173
+ doomed.size
174
+ end
175
+
176
+ # Retention: pairs created before the cutoff, TERMINAL statuses only
177
+ # (`judged`/`incomplete`) — an `open`/`complete` record older than the
178
+ # window is still someone's unjudged evidence. -> count removed.
179
+ def delete_older_than(time)
180
+ cutoff = time.utc.iso8601
181
+ doomed = each.select do |p|
182
+ %i[judged incomplete].include?(p.status) && p.created_at.to_s < cutoff
183
+ end
184
+ doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
185
+ doomed.size
186
+ end
187
+
188
+ private
189
+
190
+ # The shared upsert: read -> merge only the fields THIS half owns (never
191
+ # overwrite the other's with nil) -> recompute status -> write. Runs inside
192
+ # Store#transaction so two halves landing in the same instant serialize on
193
+ # the backend's lock — the same claim mechanic OutboxStore#claim uses.
194
+ def upsert(id, at: nil, &fill)
195
+ @store.transaction do
196
+ key = key_for(id)
197
+ record = @store.get(SCOPE, key)
198
+ created = record.nil?
199
+ unless record
200
+ record = {
201
+ "id" => id.to_s, "channel" => nil, "agent" => nil, "session_id" => nil,
202
+ "task_id" => nil, "event_id" => nil, "inbound" => nil,
203
+ "incumbent_reply" => nil, "insika_reply" => nil, "status" => "open",
204
+ "verdict" => nil, "criterion_sha" => nil,
205
+ "created_at" => arrival_time(at), "updated_at" => timestamp
206
+ }
207
+ end
208
+ fill.call(record, created)
209
+ record["status"] = status_for(record)
210
+ record["updated_at"] = timestamp
211
+ @store.set(SCOPE, key, record)
212
+ to_pair(record)
213
+ end
214
+ end
215
+
216
+ # Status recomputation, in one place (never backwards — a judged pair stays
217
+ # judged, an expired one stays incomplete):
218
+ def status_for(record)
219
+ return record["status"] if %w[judged incomplete].include?(record["status"])
220
+ return "open" if record["insika_reply"].nil? || record["incumbent_reply"].nil?
221
+ return "silent" if record["insika_reply"].to_s.strip.empty?
222
+
223
+ "complete"
224
+ end
225
+
226
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
227
+
228
+ # The mirror's reported time on first write; nil = now. A String rides
229
+ # through as-is (it is the wire format); a Time is normalized to ISO8601.
230
+ # A String is ALSO normalized to UTC ISO8601: the mirrors report local
231
+ # offsets (+09:00, -03:00) and every comparison against created_at
232
+ # (since/expire/retention/unjudged ordering) is lexicographic — two offsets
233
+ # would make those comparisons lie. Unparseable input keeps the old
234
+ # ride-through behaviour rather than refusing the pair.
235
+ def arrival_time(at)
236
+ return timestamp if at.nil?
237
+ return at.utc.iso8601 unless at.is_a?(String)
238
+
239
+ Time.iso8601(at).utc.iso8601
240
+ rescue ArgumentError
241
+ at
242
+ end
243
+
244
+ def to_pair(record)
245
+ Pair.new(
246
+ id: record["id"], channel: record["channel"], agent: record["agent"],
247
+ session_id: record["session_id"], task_id: record["task_id"],
248
+ event_id: record["event_id"], inbound: record["inbound"],
249
+ incumbent_reply: record["incumbent_reply"], insika_reply: record["insika_reply"],
250
+ status: record["status"].to_sym, verdict: record["verdict"],
251
+ criterion_sha: record["criterion_sha"],
252
+ created_at: record["created_at"], updated_at: record["updated_at"]
253
+ )
254
+ end
255
+
256
+ def timestamp = Time.now.utc.iso8601
257
+ end
258
+ end
@@ -34,6 +34,10 @@ module Insika
34
34
  @skills, @agent_skills = load_all
35
35
  end
36
36
 
37
+ # The SkillStore the catalog overlays — the composition root hands it to
38
+ # the harvest (the dedup reads the AUTHORED skills the catalog serves).
39
+ attr_reader :store
40
+
37
41
  # `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
38
42
  # same precedence chain the catalog already runs for store-over-disk and
39
43
  # workspace-over-managed-over-bundled, with one more dimension.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ module Insika
7
+ module Soak
8
+ # The frozen pre-declared envelope for a soak run. Parses the
9
+ # fenced `yaml` block inside a markdown file, validates it completely or not
10
+ # at all, and exposes the SHA-256 of the WHOLE file — every hourly snapshot
11
+ # is stamped with it, so editing the envelope mid-run turns the run
12
+ # `invalid` instead of producing a verdict nobody can defend.
13
+ #
14
+ # No defaults, no tolerance: an envelope with a hole is not a
15
+ # pre-declaration. Contrast with EnvSchema (tolerant, warn-and-boot) — that
16
+ # resilience protects a live service; this strictness protects a claim.
17
+ class Envelope
18
+ # Required keys. A missing one is a ConfigError, not a default.
19
+ REQUIRED = %i[
20
+ version target duration_hours warmup_hours
21
+ arrival turns_per_hour session_turns concurrency_cap web_concurrency
22
+ rss_growth_ratio prep_p95_drift_ratio
23
+ restarts_max error_rate_ceiling no_usage_rate_ceiling
24
+ coverage_min_ratio gap_seconds_max hourly_turn_floor
25
+ ].freeze
26
+
27
+ # Written after E1, before E2 (techspec D3). Absent -> `calibrated?` is
28
+ # false and the 72h run refuses to start; the 4h dry run does not require them.
29
+ CALIBRATED = %i[rss_ceiling_mb prep_p95_ceiling_ms total_p95_ceiling_ms].freeze
30
+
31
+ # Keys validated as ratios: must be strictly greater than 1 (the gate is
32
+ # "no more than 1.15x", so a ratio of 1 or below is a nonsense gate).
33
+ RATIO_KEYS = %i[rss_growth_ratio prep_p95_drift_ratio].freeze
34
+
35
+ # Keys validated as positive integers.
36
+ POSITIVE_COUNT_KEYS = %i[
37
+ duration_hours warmup_hours turns_per_hour session_turns
38
+ concurrency_cap web_concurrency gap_seconds_max hourly_turn_floor
39
+ ].freeze
40
+
41
+ # Keys validated as rates in [0, 1].
42
+ RATE_KEYS = %i[error_rate_ceiling no_usage_rate_ceiling coverage_min_ratio].freeze
43
+
44
+ # Raises Insika::ConfigError: file missing, no fenced yaml block,
45
+ # unparseable, a REQUIRED key absent, or a value out of range.
46
+ def self.load(path)
47
+ bytes = File.binread(path)
48
+ parse(bytes, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
49
+ rescue Errno::ENOENT
50
+ raise ConfigError, "soak envelope not found: #{path}"
51
+ end
52
+
53
+ # Pure parse (specs, fixtures). `sha` is stamped verbatim.
54
+ def self.parse(source, sha:)
55
+ new(values: validate(YAML.safe_load(fenced_yaml(source)) || {}), sha: sha)
56
+ rescue Psych::SyntaxError => e
57
+ raise ConfigError, "soak envelope yaml is unparseable: #{e.message.lines.first.to_s.strip}"
58
+ end
59
+
60
+ def self.fenced_yaml(source)
61
+ match = source.to_s.match(/```yaml\s*\n(.*?)\n```/m)
62
+ raise ConfigError, "soak envelope has no fenced yaml block" unless match
63
+
64
+ # Markdown fence content may be indented; YAML cares about the column.
65
+ lines = match[1].lines
66
+ indent = lines.reject { |l| l.strip.empty? }.map { |l| l[/\A */].length }.min
67
+ lines.map { |l| l[indent..] || "\n" }.join
68
+ end
69
+ private_class_method :fenced_yaml
70
+
71
+ def self.validate(raw)
72
+ values = raw.each_with_object({}) do |(k, v), acc|
73
+ acc[k.to_sym] = v if k.to_s =~ /\A[a-z_][a-z0-9_]*\z/
74
+ end
75
+
76
+ REQUIRED.each do |key|
77
+ raise ConfigError, "soak envelope is missing required key: #{key}" if values[key].nil?
78
+ end
79
+
80
+ RATIO_KEYS.each do |key|
81
+ v = values[key]
82
+ unless v.is_a?(Numeric) && v > 1
83
+ raise ConfigError, "soak envelope key #{key} must be > 1 (got #{v.inspect})"
84
+ end
85
+ end
86
+
87
+ POSITIVE_COUNT_KEYS.each do |key|
88
+ v = values[key]
89
+ unless v.is_a?(Integer) && v.positive?
90
+ raise ConfigError, "soak envelope key #{key} must be a positive integer (got #{v.inspect})"
91
+ end
92
+ end
93
+
94
+ unless values[:restarts_max].is_a?(Integer) && values[:restarts_max] >= 0
95
+ raise ConfigError, "soak envelope key restarts_max must be >= 0 (got #{values[:restarts_max].inspect})"
96
+ end
97
+
98
+ RATE_KEYS.each do |key|
99
+ v = values[key]
100
+ unless v.is_a?(Numeric) && v >= 0 && v <= 1
101
+ raise ConfigError, "soak envelope key #{key} must be a rate in 0..1 (got #{v.inspect})"
102
+ end
103
+ end
104
+
105
+ unless values[:warmup_hours] < values[:duration_hours]
106
+ raise ConfigError, "soak envelope key warmup_hours must be < duration_hours " \
107
+ "(got #{values[:warmup_hours]} >= #{values[:duration_hours]})"
108
+ end
109
+
110
+ CALIBRATED.each do |key|
111
+ v = values[key]
112
+ next if v.nil?
113
+
114
+ unless v.is_a?(Numeric) && v.positive?
115
+ raise ConfigError, "soak envelope key #{key} must be a positive number when set (got #{v.inspect})"
116
+ end
117
+ end
118
+
119
+ values.freeze
120
+ end
121
+ private_class_method :validate
122
+
123
+ attr_reader :sha, :values
124
+
125
+ def initialize(values:, sha:)
126
+ @values = values
127
+ @sha = sha
128
+ end
129
+
130
+ def [](key) = values[key]
131
+
132
+ def calibrated? = CALIBRATED.all? { |key| !values[key].nil? }
133
+
134
+ # E1 shape (<= 8h): the dry run skips the calibrated ceilings.
135
+ def dry_run? = values[:duration_hours] <= 8
136
+
137
+ def to_h = values.merge(sha: sha)
138
+ end
139
+ end
140
+ end