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
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require "openssl"
6
+ require "time"
6
7
  require "uri"
7
8
 
8
9
  module Insika
@@ -33,12 +34,25 @@ module Insika
33
34
  DEFAULT_ID = "relay"
34
35
  DEFAULT_TIMEOUT = 10
35
36
 
37
+ # how the outbox flushes for THIS channel. `:at_end` (the
38
+ # default) is one POST with the whole answer, byte-identical to today;
39
+ # `:progressive` lets ChannelDelivery split the answer into balloons and
40
+ # POST them in order (the channel still only translates — it does not know
41
+ # what a balloon is).
42
+ POLICIES = %i[at_end progressive].freeze
43
+
36
44
  attr_reader :id
37
45
 
38
46
  # The bundled relay as an operator configures it: three env vars, of which the
39
47
  # token is the SWITCH — no token, no channel, so there is no way to end up with
40
48
  # this route mounted and open. -> Relay | nil.
41
49
  #
50
+ # `INSIKA_RELAY_SHADOW` (truthy) is the shadow switch: the turn
51
+ # runs end to end and the reply is recorded, never delivered.
52
+ #
53
+ # `INSIKA_RELAY_DELIVERY` ("progressive" | "at_end") is how the outbox
54
+ # flushes. Unset = :at_end.
55
+ #
42
56
  # Shared by every composition root on purpose: the DSL front door has to reach
43
57
  # the same feature as `config.ru`, or the docs are true of only one of them.
44
58
  def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
@@ -48,6 +62,8 @@ module Insika
48
62
  new(inbound_token: token,
49
63
  deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
50
64
  deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
65
+ shadow: Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_RELAY_SHADOW", env)),
66
+ delivery: policy!(Insika::EnvSchema.read("INSIKA_RELAY_DELIVERY", env)),
51
67
  http: http, allow_http: allow_http, allow_private: allow_private)
52
68
  end
53
69
 
@@ -59,9 +75,14 @@ module Insika
59
75
  # reply and the delivery fails loudly instead of silently).
60
76
  # deliver_token: Bearer we send THEM. Optional: a consumer on a private
61
77
  # network may authenticate us another way.
78
+ # shadow: The turn runs, the reply is recorded and never
79
+ # sent. Fail-closed by construction: everything downstream
80
+ # duck-types `shadow?`, so a channel that does not answer it
81
+ # is a normal channel.
82
+ # delivery: How the outbox flushes (:at_end | :progressive).
62
83
  def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
63
84
  id: DEFAULT_ID, allow_http: false, allow_private: false,
64
- timeout: DEFAULT_TIMEOUT)
85
+ timeout: DEFAULT_TIMEOUT, shadow: false, delivery: :at_end)
65
86
  @id = id.to_s
66
87
  @inbound_token = inbound_token.to_s
67
88
  @deliver_url = deliver_url.to_s
@@ -70,10 +91,30 @@ module Insika
70
91
  @allow_http = allow_http
71
92
  @allow_private = allow_private
72
93
  @timeout = timeout
94
+ @shadow = shadow
95
+ @delivery = self.class.policy!(delivery)
96
+ end
97
+
98
+ def shadow? = @shadow
99
+
100
+ def delivery = @delivery
101
+ def progressive? = @delivery == :progressive
102
+
103
+ # "progressive" | "at_end" | blank/unset (= :at_end). An unknown value is
104
+ # a config error at BOOT — the consumer would silently miss every
105
+ # progressive turn, so it is refused where the operator is.
106
+ def self.policy!(value)
107
+ return :at_end if Insika::Coercion.blank?(value)
108
+
109
+ name = value.to_s.strip.downcase.to_sym
110
+ return name if POLICIES.include?(name)
111
+
112
+ raise Insika::ConfigError,
113
+ "unknown relay delivery: #{value.inspect} (expected #{POLICIES.join(', ')})"
73
114
  end
74
115
 
75
116
  # -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
76
- # RFC sketched one): a status code is the transport's vocabulary, and keeping
117
+ # the design sketched one): a status code is the transport's vocabulary, and keeping
77
118
  # it out of here is what lets this class be tested without Rack and read
78
119
  # without knowing HTTP.
79
120
  def authenticate(req)
@@ -91,19 +132,25 @@ module Insika
91
132
  # { "agent": "support", "external_id": "5511999998888",
92
133
  # "event_id": "wamid.HBg…", "message": "queria saber do pedido",
93
134
  # "vars": { … } }
135
+ #
136
+ # In SHADOW mode `event_id` is REQUIRED: it is the correlation key both
137
+ # halves of the pair are built from, and a mirror that cannot supply a
138
+ # stable id cannot be paired.
94
139
  def parse(_req, body:)
95
140
  body = body.is_a?(Hash) ? body : {}
96
141
  agent = string(body["agent"])
97
142
  external_id = string(body["external_id"])
98
143
  message = string(body["message"])
144
+ event_id = presence(body["event_id"])
99
145
 
100
146
  raise Insika::ValidationError, "agent is required" if agent.empty?
101
147
  raise Insika::ValidationError, "external_id is required" if external_id.empty?
102
148
  raise Insika::ValidationError, "message is required" if message.strip.empty?
149
+ raise Insika::ValidationError, "event_id is required in shadow mode" if @shadow && event_id.nil?
103
150
 
104
151
  vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
105
152
  { agent: agent, external_id: external_id, message: message,
106
- event_id: presence(body["event_id"]), vars: vars }
153
+ event_id: event_id, vars: vars, incumbent_reply: presence(body["incumbent_reply"]) }
107
154
  end
108
155
 
109
156
  # the engine namespaces the platform's conversation key, so a
@@ -127,6 +174,8 @@ module Insika
127
174
  # consumer that receives the same delivery twice (we retried after a timeout
128
175
  # that actually landed) can drop the second one.
129
176
  def deliver(payload, to:, delivery_id: nil)
177
+ raise Insika::DeliveryError, "relay '#{@id}' is in shadow mode and must never deliver" if @shadow
178
+
130
179
  raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
131
180
 
132
181
  if (reason = egress_violation)
@@ -143,8 +192,33 @@ module Insika
143
192
  raise Insika::DeliveryError, "#{e.class}: #{e.message}"
144
193
  end
145
194
 
195
+ # The reply, as recorded by the mirror. Follows the
196
+ # same strictness as `parse`; `at` is optional (nil = now).
197
+ def parse_shadow_reply(_req, body:)
198
+ body = body.is_a?(Hash) ? body : {}
199
+ external_id = string(body["external_id"])
200
+ event_id = presence(body["event_id"])
201
+ reply = string(body["reply"])
202
+
203
+ raise Insika::ValidationError, "external_id is required" if external_id.empty?
204
+ raise Insika::ValidationError, "event_id is required" if event_id.nil?
205
+ raise Insika::ValidationError, "reply is required" if reply.strip.empty?
206
+
207
+ at = presence(body["at"])
208
+ raise Insika::ValidationError, "at must be an ISO8601 timestamp" if at && !parseable_time?(at)
209
+
210
+ { external_id: external_id, event_id: event_id, reply: reply, at: at }
211
+ end
212
+
146
213
  private
147
214
 
215
+ def parseable_time?(value)
216
+ Time.iso8601(value)
217
+ true
218
+ rescue ArgumentError
219
+ false
220
+ end
221
+
148
222
  # Resolved on EVERY call, not once at boot: a hostname that answered a public
149
223
  # address yesterday can answer 169.254.169.254 today, and this POST carries
150
224
  # the customer's conversation.
@@ -7,7 +7,7 @@ require "securerandom"
7
7
  module Insika
8
8
  module Channels
9
9
  # The web widget — the FIRST Shape A channel, and the adoption
10
- # claim behind the whole RFC: an adopter pastes one tag into their site and has
10
+ # claim behind the whole design: an adopter pastes one tag into their site and has
11
11
  # a working agent, with no backend of their own and no build step.
12
12
  #
13
13
  # <script src="https://agents.example.com/channels/web/asset/widget.js"
@@ -50,7 +50,7 @@ module Insika
50
50
  # never reach a browser that already has it. Short max-age + an ETag instead:
51
51
  # the common case is a 304 with no body, and an upgrade lands within minutes.
52
52
  # (A deliberate narrowing of's "long-cache versioned URL": the install
53
- # snippet in the RFC has no version in it, so there is nothing to bust.)
53
+ # snippet has no version in it, so there is nothing to bust.)
54
54
  ASSET_CACHE_CONTROL = "public, max-age=300"
55
55
 
56
56
  attr_reader :id
@@ -14,7 +14,8 @@ module Insika
14
14
  class ChatBuilder
15
15
  def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
16
  hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
- tool_trace_store: nil)
17
+ tool_trace_store: nil, media_runner: nil, session_store: nil,
18
+ contact_store: nil, followup_store: nil)
18
19
  @tool_registry = tool_registry
19
20
  @skill_catalog = skill_catalog
20
21
  @checkpoint_store = checkpoint_store
@@ -28,6 +29,19 @@ module Insika
28
29
  # spawn_subagent system tool is never wired (parity for a builder used
29
30
  # without delegation, e.g. some unit stubs).
30
31
  @subagent_runner = subagent_runner
32
+ # the Executor as the generate_image/tts runner (seams + usage
33
+ # accounting). nil = the media tools are never wired (parity for stubs).
34
+ @media_runner = media_runner
35
+ # update_briefing / set_next_step are the briefing-write system
36
+ # tools — wired only with @session_store present AND profile.briefing_fields
37
+ # non-empty (double gate, like remember). nil = never wired (parity for a
38
+ # builder used without session persistence, e.g. some unit stubs).
39
+ @session_store = session_store
40
+ # the schedule/cancel_followup system tools — wired only
41
+ # with BOTH stores present AND a parsed follow-up policy on the profile
42
+ # (double gate, like remember). nil = never wired (parity).
43
+ @contact_store = contact_store
44
+ @followup_store = followup_store
31
45
  end
32
46
 
33
47
  # Configures an already-created chat with the context (stage 2) and the
@@ -90,6 +104,22 @@ module Insika
90
104
  event_stream: @event_stream, state: state)
91
105
  end
92
106
 
107
+ # update_briefing / set_next_step are the briefing-write system tools
108
+ # — wired only with @session_store present AND
109
+ # profile.briefing_fields non-empty (double gate, like remember). Never
110
+ # enveloped: deterministic in-process writes.
111
+ fields = if state.profile.respond_to?(:briefing_fields)
112
+ Array(state.profile.briefing_fields).map(&:to_s)
113
+ else
114
+ []
115
+ end
116
+ if @session_store && !fields.empty?
117
+ tools << Tools::UpdateBriefing.new(session_store: @session_store, fields: fields,
118
+ event_stream: @event_stream, state: state)
119
+ tools << Tools::UpdateBriefing::SetNextStep.new(session_store: @session_store,
120
+ event_stream: @event_stream, state: state)
121
+ end
122
+
93
123
  # signal_stuck is the "I cannot proceed" system tool (WS5) — wired only
94
124
  # when the agent opted in (`profile.stuck_signal`), never enveloped. It is a
95
125
  # deterministic signal; the consumer decides what "stuck" means. Defensive
@@ -98,6 +128,33 @@ module Insika
98
128
  tools << Tools::StuckSignal.new(state: state)
99
129
  end
100
130
 
131
+ # schedule/cancel_followup are the follow-up system tools —
132
+ # wired only with BOTH stores present AND a parsed follow-up policy on
133
+ # the profile (double gate, like remember). Never enveloped: the store
134
+ # writes are deterministic. The tools stay OUT of the allowlist — they
135
+ # are data-driven per agent, not admittable (prompts/memory/stuck_signal
136
+ # are the precedent).
137
+ if @contact_store && @followup_store && followup_policy(state)
138
+ tools << Tools::ScheduleFollowup.new(contact_store: @contact_store,
139
+ followup_store: @followup_store,
140
+ state: state,
141
+ event_stream: @event_stream)
142
+ tools << Tools::CancelFollowup.new(followup_store: @followup_store,
143
+ state: state,
144
+ event_stream: @event_stream)
145
+ end
146
+
147
+ # generate_image / tts are the generated-media system tools (WS9, saída)
148
+ # — wired only when BOTH gates pass, never enveloped: the AGENT opted in
149
+ # (`profile.outputs` — the per-kind generator config) AND the CHANNEL
150
+ # declared it can receive the media (state.channel_capabilities, from the
151
+ # request's `channel.capabilities`). The "abstraction admits only what
152
+ # leaks" rule, C4: a channel that never declared image_output cannot get
153
+ # a generated image; a profile without `outputs` never generates. The
154
+ # runner is the Executor (seams + usage accounting); nil = no media
155
+ # output at all (parity for a stub builder).
156
+ output_media_tools(state).each { |tool| tools << tool } if @media_runner
157
+
101
158
  # spawn_subagent is the delegation system tool — wired only with a
102
159
  # runner present AND profile.subagents non-empty (a double gate, like
103
160
  # remember). Never enveloped: in the synchronous mode the child lives in the
@@ -139,6 +196,32 @@ module Insika
139
196
  names - eager
140
197
  end
141
198
 
199
+ # the follow-up tools' gate — a PARSED policy on the profile.
200
+ # A malformed declaration reads as "no policy" (the firer blocks it, the
201
+ # doctor reports it; the tools are simply not offered).
202
+ def followup_policy(state)
203
+ followup = state.profile.respond_to?(:followup) ? state.profile.followup : nil
204
+ followup && Insika::FollowupPolicy.parse(followup)
205
+ end
206
+
207
+ # WS9 (saída): the media-output tools this turn may carry, per the double
208
+ # gate (profile outputs ∩ channel capabilities). [] = none. Defensive reads
209
+ # throughout — a minimal profile/state stub is "off", which is the safe
210
+ # parity reading.
211
+ def output_media_tools(state)
212
+ outputs = state.profile.respond_to?(:outputs) ? state.profile.outputs : nil
213
+ return [] unless outputs.is_a?(Hash)
214
+ return [] unless state.respond_to?(:channel_capabilities)
215
+
216
+ caps = Array(state.channel_capabilities).map(&:to_s)
217
+ image_cfg = outputs["image"]
218
+ tts_cfg = outputs["tts"]
219
+ [
220
+ (Tools::GenerateImage.new(runner: @media_runner, config: image_cfg, state: state) if image_cfg.is_a?(Hash) && caps.include?("image_output")),
221
+ (Tools::Tts.new(runner: @media_runner, config: tts_cfg, state: state) if tts_cfg.is_a?(Hash) && caps.include?("audio_output"))
222
+ ].compact
223
+ end
224
+
142
225
  # The turn's effective tool concurrency (nil = serial), plus the ONE thing the
143
226
  # gate owes the operator: when the profile asked for parallel tool calls and
144
227
  # this turn silently cannot have them (an approval-required tool would
@@ -109,6 +109,22 @@ module Insika
109
109
  nil
110
110
  end
111
111
 
112
+ # Deletes EVERY checkpoint + side-effect record of the task (WS8 retention:
113
+ # a purged task's durability trail goes with it — no orphaned keys).
114
+ # -> count of records removed.
115
+ def purge(task_id)
116
+ removed = 0
117
+ checkpoint_turns(task_id).each do |n|
118
+ @store.delete(SCOPE, checkpoint_key(task_id, n))
119
+ removed += 1
120
+ end
121
+ sideeffect_turns(task_id).each do |n|
122
+ @store.delete(SCOPE, sideeffects_key(task_id, n))
123
+ removed += 1
124
+ end
125
+ removed
126
+ end
127
+
112
128
  private
113
129
 
114
130
  def checkpoint_key(task_id, turn)
@@ -12,10 +12,10 @@ module Insika
12
12
  FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
13
13
  tools_allow_groups skills skills_eager context_providers workflows_allow policies
14
14
  prompt_refs limits approvals_required capabilities subagents tools_deferred
15
- memory prompt_caching tool_output_compression budget reliability alerts
16
- stuck_signal
15
+ memory prompt_caching tool_persistence tool_output_compression budget reliability alerts
16
+ routes stuck_signal outputs briefing_fields grounding funnel followup
17
17
  params model_policy guardrails refinement capabilities_declared
18
- edge_stream metadata].freeze
18
+ edge_stream metadata distill harvest].freeze
19
19
 
20
20
  module_function
21
21
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # the operator cancels ONE pending follow-up record (the
8
+ # Studio's Cancel button dispatches this). Synchronous control command (no
9
+ # task, like RecordOutcome); idempotent — an already-cancelled record
10
+ # returns as-is (a repeat of the same call is not an error). Tenant-scoped
11
+ # (WS1): a tenant principal can only cancel its own tenant's records.
12
+ class CancelFollowup
13
+ def initialize(followup_store:, event_stream:)
14
+ @followup_store = followup_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ # -> Record. Emits :followup_cancelled { id:, cancelled_by: }.
19
+ def call(command)
20
+ id = Coercion.presence(command.payload[:followup_id] || command.payload["followup_id"])
21
+ raise ValidationError, "followup_id is required" if id.nil?
22
+
23
+ record = @followup_store.find(id)
24
+ raise NotFoundError, "follow-up not found: #{id}" if record.nil?
25
+
26
+ # WS1: the principal's tenant is the scope — a tenant token cannot
27
+ # cancel another tenant's record by id.
28
+ tenant = command.meta[:tenant]
29
+ unless record.tenant == (tenant.to_s.empty? ? "platform" : tenant.to_s)
30
+ raise ValidationError, "follow-up #{id} belongs to another tenant"
31
+ end
32
+
33
+ # A non-pending record (the tick fired it between the render and the
34
+ # click) is a domain error the Studio can flash — never a 500.
35
+ cancelled = begin
36
+ @followup_store.cancel(id: id)
37
+ rescue ArgumentError => e
38
+ raise ValidationError, e.message
39
+ end
40
+ @event_stream.emit(Insika::Event.new(
41
+ type: :followup_cancelled,
42
+ data: { id: cancelled.id, cancelled_by: "operator" },
43
+ meta: { tenant: command.meta[:tenant], at: Time.now.utc.iso8601 }
44
+ ))
45
+ cancelled
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges everything the engine holds
8
+ # about ONE TENANT — its sessions (the "<tenant>:" namespace) and everything
9
+ # those sessions left behind (traces, tasks, checkpoints, outbox deliveries
10
+ # — see SessionPurge), every memory cell under the tenant (its own + the
11
+ # customer cells), its outcome records (WS7) and its API CREDENTIALS (every
12
+ # active token of the tenant is revoked first — an offboarded tenant must
13
+ # not authenticate). The tenant string IS the
14
+ # isolation boundary, so zeroing it cannot touch another tenant's data.
15
+ # Operator-only BY CONSTRUCTION: the generic command ingress is
16
+ # operator-grade (a tenant principal never reaches it).
17
+ class DeleteTenantData
18
+ include SessionPurge
19
+
20
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
21
+ context_trace_store: nil, model_visible_trace_store: nil,
22
+ outcome_store: nil, task_store: nil,
23
+ checkpoint_store: nil, outbox_store: nil, shadow_pairs: nil,
24
+ token_store: nil, funnel_store: nil, event_stream:,
25
+ followup_store: nil, contact_store: nil, proposal_store: nil,
26
+ harvest_store: nil)
27
+ @memory_store = memory_store
28
+ @token_store = token_store
29
+ @session_store = session_store
30
+ @tool_trace_store = tool_trace_store
31
+ @context_trace_store = context_trace_store
32
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
33
+ @outcome_store = outcome_store
34
+ @task_store = task_store
35
+ @checkpoint_store = checkpoint_store
36
+ @outbox_store = outbox_store
37
+ @shadow_pairs = shadow_pairs
38
+ @funnel_store = funnel_store # ; nil = nothing to sweep
39
+ @followup_store = followup_store # ; nil = nothing to sweep
40
+ @contact_store = contact_store # ; nil = nothing to sweep
41
+ @proposal_store = proposal_store # ; nil = nothing to sweep
42
+ @harvest_store = harvest_store # ; nil = nothing to sweep
43
+ @event_stream = event_stream
44
+ end
45
+
46
+ # -> { tenant:, sessions:, memory_records:, outcomes:, tokens_revoked:,
47
+ # tasks:, checkpoints:, deliveries:, followups:, contacts: }.
48
+ def call(command)
49
+ tenant = Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
50
+ raise ValidationError, "tenant is required" if tenant.nil?
51
+
52
+ # CREDENTIALS FIRST (WS1+WS8): erasing the data while the tenant's
53
+ # tokens still resolve leaves an offboarded tenant authenticating and
54
+ # opening a NEW session — the purge would report success over a live
55
+ # customer. Revoking before the sweep closes the door, so nothing the
56
+ # tenant does mid-purge survives it. nil store = single_tenant mode
57
+ # (no per-tenant credential exists).
58
+ tokens_revoked = @token_store ? @token_store.revoke_all(tenant_id: tenant) : 0
59
+
60
+ sessions = @session_store.each_id.select { |id| id.to_s.start_with?("#{tenant}:") }
61
+ purged = purge_sessions(sessions)
62
+
63
+ memory_records = @memory_store.purge_tenant(tenant)
64
+ outcomes = @outcome_store ? @outcome_store.purge(tenant: tenant) : 0
65
+ funnel = @funnel_store ? @funnel_store.purge(tenant: tenant) : 0
66
+ # the follow-up footprint dies with the tenant — records
67
+ # and contact cells under the same tenant prefix.
68
+ followups = @followup_store ? @followup_store.purge(tenant: tenant) : 0
69
+ contacts = @contact_store ? @contact_store.purge(tenant: tenant) : 0
70
+ # the distilled proposals die with the tenant.
71
+ proposals = @proposal_store ? @proposal_store.purge(tenant: tenant) : 0
72
+ # candidates reference sessions, and sessions carry the
73
+ # tenant prefix — the harvest rows die with the tenant (D11).
74
+ harvest = @harvest_store ? @harvest_store.purge(tenant: tenant) : 0
75
+
76
+ @event_stream.emit(Insika::Event.new(
77
+ type: :tenant_data_deleted,
78
+ data: { tenant: tenant, sessions: sessions,
79
+ memory_records: memory_records,
80
+ outcomes: outcomes,
81
+ funnel: funnel,
82
+ followups: followups,
83
+ contacts: contacts,
84
+ proposals: proposals,
85
+ harvest: harvest,
86
+ tokens_revoked: tokens_revoked }.merge(purged),
87
+ meta: { at: Time.now.utc.iso8601 }
88
+ ))
89
+ { tenant: tenant, sessions: sessions, memory_records: memory_records,
90
+ outcomes: outcomes, funnel: funnel, followups: followups, contacts: contacts,
91
+ proposals: proposals, harvest: harvest, tokens_revoked: tokens_revoked }.merge(purged)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command the LGPD access right): exports what the
8
+ # engine holds about ONE customer — their memory cell's facts + notes,
9
+ # expired facts excluded. The FULL content is the RETURN VALUE (the Studio
10
+ # turns it into a JSON download); the emitted event carries counts only, so
11
+ # the event stream, the audit store and every log stay content-free (D7).
12
+ # The return value never enters a store, an event or a log.
13
+ class ExportCustomerMemory
14
+ def initialize(memory_store:, event_stream:)
15
+ @memory_store = memory_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ # payload: { customer: (REQUIRED), tenant: } — customer from the payload
20
+ # (an operator names the person); tenant from the payload || meta.
21
+ # -> { "customer" =>, "tenant" =>, "exported_at" =>, "facts" => [Fact#to_h],
22
+ # "notes" => [Note#to_h], "counts" => { "facts" =>, "notes" => } }
23
+ def call(command)
24
+ p = AgentPayload.symbolize(command.payload)
25
+ customer = AgentPayload.presence(p[:customer])
26
+ raise Insika::ValidationError, "customer is required" if customer.nil?
27
+
28
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
29
+ facts = @memory_store.facts(tenant: tenant, customer: customer)
30
+ notes = @memory_store.notes(tenant: tenant, customer: customer)
31
+ exported_at = Time.now.utc.iso8601
32
+
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :customer_memory_exported,
35
+ data: { customer: customer, tenant: tenant,
36
+ counts: { facts: facts.size, notes: notes.size } },
37
+ meta: { at: exported_at }
38
+ ))
39
+ {
40
+ "customer" => customer, "tenant" => tenant, "exported_at" => exported_at,
41
+ "facts" => facts.map { |f| Coercion.deep_stringify(f.to_h) },
42
+ "notes" => notes.map { |n| Coercion.deep_stringify(n.to_h) },
43
+ "counts" => { "facts" => facts.size, "notes" => notes.size }
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges what the engine holds about
8
+ # ONE customer — the customer's memory cell, their sessions (found through
9
+ # the `customer` var the Executor stamps on a tagged conversation) and
10
+ # everything those sessions left behind (traces, tasks, checkpoints, outbox
11
+ # deliveries — see SessionPurge). The scope string IS the isolation
12
+ # boundary, so zeroing it cannot touch another customer's or another
13
+ # tenant's data. Operator-only BY CONSTRUCTION: the generic command ingress
14
+ # is operator-grade (a tenant principal never reaches it).
15
+ #
16
+ # WHICH TENANT is the whole correctness of the purge: the memory cell is
17
+ # "memory:<tenant>:<customer>" and the sessions are the "<tenant>:" ones. It
18
+ # is read from the command meta (an internal caller acting AS a tenant) or
19
+ # from the payload (`{ customer:, tenant: }` — the operator naming it over
20
+ # HTTP, where the principal has no tenant of its own). Absent from both, the
21
+ # purge is deployment-wide: the untagged memory cell plus that customer's
22
+ # sessions in EVERY tenant — right for a single-tenant deployment, and never
23
+ # what a multi-tenant operator means, so they must name the tenant.
24
+ class ForgetCustomer
25
+ include SessionPurge
26
+
27
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
28
+ context_trace_store: nil, model_visible_trace_store: nil,
29
+ task_store: nil, checkpoint_store: nil,
30
+ outbox_store: nil, shadow_pairs: nil, audit_store: nil, event_stream:,
31
+ followup_store: nil, contact_store: nil, proposal_store: nil)
32
+ @memory_store = memory_store
33
+ @session_store = session_store
34
+ @tool_trace_store = tool_trace_store
35
+ @context_trace_store = context_trace_store
36
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
37
+ @task_store = task_store
38
+ @checkpoint_store = checkpoint_store
39
+ @outbox_store = outbox_store
40
+ @shadow_pairs = shadow_pairs
41
+ @audit_store = audit_store
42
+ @event_stream = event_stream
43
+ @followup_store = followup_store # ; nil = nothing to sweep
44
+ @contact_store = contact_store # ; nil = nothing to sweep
45
+ @proposal_store = proposal_store # ; nil = nothing to sweep
46
+ end
47
+
48
+ # -> { customer:, tenant:, memory_records:, sessions: [], tasks:,
49
+ # checkpoints:, deliveries:, followups:, contacts: }.
50
+ def call(command)
51
+ customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
52
+ raise ValidationError, "customer is required" if customer.nil?
53
+
54
+ tenant = command.meta[:tenant] ||
55
+ Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
56
+ memory_scope = [tenant, customer].compact.join(":")
57
+ memory_records = @memory_store.purge(tenant: memory_scope)
58
+
59
+ # the follow-up footprint dies with the person — the
60
+ # schedule records and the contact cell (LGPD).
61
+ followups = @followup_store&.purge_customer(tenant: tenant, customer: customer) || 0
62
+ contacts = @contact_store&.delete(tenant: tenant, customer: customer) ? 1 : 0
63
+
64
+ # the distilled PROPOSALS die with the person — a proposal
65
+ # is born inside a customer cell (D6), so forget_customer reaches it.
66
+ proposals = @proposal_store&.purge_customer(tenant: tenant, customer: customer) || 0
67
+
68
+ sessions = session_ids_for(customer, tenant)
69
+ purged = purge_sessions(sessions)
70
+
71
+ # the audit records the thing that happened, content-free —
72
+ # a digest-free line with the counts (the deleted VALUES never enter
73
+ # the audit store). Written AFTER the purge, so the line describes a
74
+ # deletion that actually happened. nil audit_store = no-op.
75
+ @audit_store&.record(
76
+ cell: @memory_store.cell_for(tenant, customer),
77
+ action: "purge", actor: operator(command), tenant: tenant, customer: customer,
78
+ note: "memory_records: #{memory_records}, sessions: #{sessions.size}"
79
+ )
80
+
81
+ @event_stream.emit(Insika::Event.new(
82
+ type: :customer_forgotten,
83
+ data: { customer: customer, tenant: tenant,
84
+ memory_records: memory_records,
85
+ sessions: sessions,
86
+ followups: followups,
87
+ contacts: contacts,
88
+ proposals: proposals }.merge(purged),
89
+ meta: { at: Time.now.utc.iso8601 }
90
+ ))
91
+ { customer: customer, tenant: tenant, memory_records: memory_records,
92
+ sessions: sessions, followups: followups, contacts: contacts,
93
+ proposals: proposals }.merge(purged)
94
+ end
95
+
96
+ private
97
+
98
+ def operator(command)
99
+ Coercion.presence(command.payload[:operator] || command.payload["operator"]) || "operator"
100
+ end
101
+
102
+ # The customer's sessions: the ones the Executor stamped with this
103
+ # customer var — plus, in multi_tenant, only ids inside the tenant's
104
+ # "<tenant>:" namespace (single_tenant sessions carry no prefix).
105
+ def session_ids_for(customer, tenant)
106
+ @session_store.each_id.each_with_object([]) do |id, acc|
107
+ session = @session_store.find(id)
108
+ next unless session
109
+ next unless Coercion.presence(session.vars["customer"]).to_s == customer.to_s
110
+ next if tenant && !id.to_s.start_with?("#{tenant}:")
111
+
112
+ acc << id.to_s
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end