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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- 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
|
-
#
|
|
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["
|
|
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.
|
data/lib/insika/channels/web.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
data/lib/insika/chat_builder.rb
CHANGED
|
@@ -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
|