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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # C4 — the incumbent's half of a shadow pair, recorded on the mirror's
8
+ # word. One command, two doors: Shape 1 (the reply rides the mirror call
9
+ # itself) and Shape 2 (the follow-up route, for a consumer that mirrors
10
+ # before answering) both dispatch HERE, so there is one behaviour and one
11
+ # store method behind both.
12
+ #
13
+ # Idempotency is first-write-wins: the customer received ONE reply, and
14
+ # letting a retry overwrite it would silently rewrite evidence. The
15
+ # guarantee lives in ShadowPairStore#record_incumbent (inside its
16
+ # transaction); the find below is only the cheap fast path for the common
17
+ # retry. A reply for a pair that does not exist yet creates it :open — the
18
+ # mirror may legitimately arrive before our turn finishes.
19
+ class RecordShadowReply
20
+ def initialize(shadow_pairs:, event_stream: nil)
21
+ @shadow_pairs = shadow_pairs
22
+ @event_stream = event_stream
23
+ end
24
+
25
+ # payload: { channel:, external_id:, event_id:, reply:, at: }
26
+ # -> { pair_id:, status: }
27
+ def call(command)
28
+ payload = command.payload
29
+ channel = Coercion.presence(payload[:channel] || payload["channel"])
30
+ external_id = Coercion.presence(payload[:external_id] || payload["external_id"])
31
+ event_id = Coercion.presence(payload[:event_id] || payload["event_id"])
32
+ reply = payload[:reply] || payload["reply"]
33
+
34
+ raise ValidationError, "channel is required" if channel.nil?
35
+ raise ValidationError, "external_id is required" if external_id.nil?
36
+ raise ValidationError, "event_id is required" if event_id.nil?
37
+ raise ValidationError, "reply is required" if reply.to_s.strip.empty?
38
+
39
+ id = ShadowPairStore.key_for(channel: channel, external_id: external_id,
40
+ event_id: event_id)
41
+ existing = @shadow_pairs.find(id)
42
+ if existing && !existing.incumbent_reply.nil?
43
+ emit(id, "already_recorded")
44
+ return { pair_id: id, status: "already_recorded" }
45
+ end
46
+
47
+ pair = @shadow_pairs.record_incumbent(id: id, channel: channel, event_id: event_id,
48
+ external_id: external_id, reply: reply.to_s,
49
+ at: payload[:at] || payload["at"])
50
+ emit(id, pair.status.to_s)
51
+ { pair_id: id, status: pair.status.to_s }
52
+ end
53
+
54
+ private
55
+
56
+ # Metadata only: the pair id and its status, never the reply's text.
57
+ def emit(pair_id, status)
58
+ return unless @event_stream
59
+
60
+ @event_stream.emit(Insika::Event.new(
61
+ type: :shadow_reply_recorded,
62
+ data: { pair_id: pair_id, status: status },
63
+ meta: { at: Time.now.utc.iso8601 }
64
+ ))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the operator's dismissal: a candidate that is NOT yet
8
+ # gated can be rejected by a human directly (a human may always outvote
9
+ # the miner). The thin wrapper over `mark_rejected`.
10
+ class RejectHarvest
11
+ def initialize(harvest_store:, event_stream:)
12
+ @harvest_store = harvest_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ # payload: { candidate_id:, operator?, note? }
17
+ # -> Candidate (rejected)
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ candidate_id = AgentPayload.presence(p[:candidate_id])
21
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
22
+
23
+ candidate = @harvest_store.find_candidate(candidate_id) ||
24
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
25
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
26
+ rejected = @harvest_store.mark_rejected(candidate.id, operator: operator, note: p[:note])
27
+
28
+ @event_stream.emit(Insika::Event.new(
29
+ type: :harvest_rejected,
30
+ data: { candidate_id: candidate.id, agent: candidate.agent,
31
+ operator: operator },
32
+ meta: { at: Time.now.utc.iso8601 }
33
+ ))
34
+ rejected
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # the human's answer : approve (→ the
6
+ # store, CAS, provenance `distilled:<session_ref>`), reject (with an
7
+ # optional reason), dismiss (latches the tuple). Approval never silently
8
+ # overwrites an operator edit (D5/E3): the CAS baseline captured at distill
9
+ # time guards the write, and a lost race flips the proposal to `:stale`
10
+ # carrying the fact's CURRENT value — both values visible on the wiki.
11
+ # Synchronous control command — the Studio dispatches it; no task, no actor.
12
+ class ResolveProposal
13
+ DECISIONS = %w[approved rejected dismissed].freeze
14
+
15
+ def initialize(proposal_store:, memory_store:, event_stream:)
16
+ @proposal_store = proposal_store
17
+ @memory_store = memory_store
18
+ @event_stream = event_stream
19
+ end
20
+
21
+ # payload: { proposal_id:, decision: "approved"|"rejected"|"dismissed",
22
+ # operator?, note? }
23
+ # operator from payload || command.meta[:operator] || "operator"
24
+ # -> Proposal (the resolved record; a :stale one carries current_value)
25
+ def call(command)
26
+ proposal_id = Coercion.presence(command.payload[:proposal_id] || command.payload["proposal_id"])
27
+ raise ValidationError, "proposal_id is required" if proposal_id.nil?
28
+
29
+ decision = Coercion.presence(command.payload[:decision] || command.payload["decision"])
30
+ raise ValidationError, "decision is required" unless DECISIONS.include?(decision)
31
+
32
+ proposal = @proposal_store.find(proposal_id)
33
+ raise Insika::NotFoundError, "proposal not found: #{proposal_id}" if proposal.nil?
34
+
35
+ operator = operator_for(command)
36
+ note = Coercion.presence(command.payload[:note] || command.payload["note"])
37
+
38
+ case decision
39
+ when "approved" then approve(command, proposal, operator, note)
40
+ when "rejected"
41
+ resolved = @proposal_store.reject(id: proposal.id, operator: operator, note: note)
42
+ emit(:proposal_rejected, resolved)
43
+ resolved
44
+ else # "dismissed"
45
+ resolved = @proposal_store.dismiss(id: proposal.id, operator: operator, note: note)
46
+ emit(:proposal_dismissed, resolved)
47
+ resolved
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # The whole safety argument (D5/E3). The read-check-write CAS rides the
54
+ # store's transaction — a lost race is a re-present, never a silent
55
+ # overwrite.
56
+ def approve(command, proposal, operator, note)
57
+ fact = @memory_store.get_fact(tenant: proposal.tenant, customer: proposal.customer,
58
+ key: proposal.key)
59
+ if proposal.expected_existed
60
+ if fact && fact.updated_at == proposal.expected_revision
61
+ written = @memory_store.replace_if_revision(
62
+ tenant: proposal.tenant, customer: proposal.customer,
63
+ key: proposal.key, value: proposal.value,
64
+ expected_revision: proposal.expected_revision,
65
+ origin: "distilled:#{proposal.session_ref}")
66
+ return stale(proposal, fact, operator) unless written
67
+ else
68
+ return stale(proposal, fact, operator) # the operator's edit stands
69
+ end
70
+ elsif fact
71
+ return stale(proposal, fact, operator) # the operator created it first
72
+ else
73
+ @memory_store.put_fact(tenant: proposal.tenant, customer: proposal.customer,
74
+ key: proposal.key, value: proposal.value,
75
+ origin: "distilled:#{proposal.session_ref}")
76
+ end
77
+ resolved = @proposal_store.approve(id: proposal.id, operator: operator, note: note)
78
+ emit(:proposal_approved, resolved)
79
+ resolved
80
+ end
81
+
82
+ # The re-present (E3): the proposal carries the fact's CURRENT value next
83
+ # to the proposed one; both are visible on the wiki page.
84
+ def stale(proposal, fact, operator)
85
+ resolved = @proposal_store.mark_stale(id: proposal.id, current_value: fact&.value,
86
+ operator: operator)
87
+ emit(:proposal_stale, resolved)
88
+ resolved
89
+ end
90
+
91
+ # ids and statuses only (D7) — a fact value never enters the stream.
92
+ def emit(type, proposal)
93
+ @event_stream.emit(Insika::Event.new(
94
+ type: type,
95
+ data: { proposal_id: proposal.id, status: proposal.status,
96
+ operator: proposal.operator },
97
+ meta: { at: Time.now.utc.iso8601 }
98
+ ))
99
+ end
100
+
101
+ def operator_for(command)
102
+ Coercion.presence(command.payload[:operator] || command.payload["operator"]) ||
103
+ Coercion.presence(command.meta[:operator]) ||
104
+ "operator"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # a channel opt-out event (mapped by the integration)— or
8
+ # the operator force-revoking a contact whose opt-out never arrived.
9
+ # Revokes the contact cell AND cancels every pending record of that
10
+ # customer in ONE transaction on the shared backend (D2: a half-cancelled
11
+ # opt-out is the spam bug — a crash mid-revoke must leave the old state,
12
+ # never the cell revoked with pending records alive). Blocked/fired
13
+ # records are never touched. Tenant-scoped (WS1).
14
+ class RevokeContact
15
+ def initialize(contact_store:, followup_store:, store:, event_stream:)
16
+ @contact_store = contact_store
17
+ @followup_store = followup_store
18
+ @store = store
19
+ @event_stream = event_stream
20
+ end
21
+
22
+ # -> { customer:, tenant:, cancelled: }.
23
+ def call(command)
24
+ customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
25
+ raise ValidationError, "customer is required" if customer.nil?
26
+
27
+ tenant = command.meta[:tenant]
28
+ # ONE transaction on the shared backend: both stores' writes (each
29
+ # wrapped in their own nested transaction) join the outer one.
30
+ cancelled = @store.transaction do
31
+ @contact_store.set_revoked(tenant: tenant, customer: customer)
32
+ @followup_store.cancel_pending_for(tenant: tenant, customer: customer)
33
+ end
34
+
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :contact_revoked,
37
+ data: { customer: customer, tenant: tenant },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ @event_stream.emit(Insika::Event.new(
41
+ type: :followups_cancelled,
42
+ data: { customer: customer, count: cancelled },
43
+ meta: { tenant: tenant, at: Time.now.utc.iso8601 }
44
+ ))
45
+ { customer: customer, tenant: tenant, cancelled: cancelled }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "securerandom"
5
+
6
+ module Insika
7
+ module Commands
8
+ # — the snapshot restored. `RollbackHarvest(snapshot_ref:)`
9
+ # restores the pre-promotion state deterministically: WriteSkill with the
10
+ # snapshot's pre-promotion content (or DeleteSkill when the skill did not
11
+ # exist before — the snapshot's `existed` flag), SetSkillAgents to the
12
+ # snapshot's pre-promotion `enabled_for`, and the `rolled_back_at` stamp on
13
+ # the SAME log (the log stays the single ledger: a skill promoted, rolled
14
+ # back, re-promoted is three readable rows). The "rehearsed once
15
+ # end-to-end" acceptance is this command's full promote -> rollback cycle.
16
+ class RollbackHarvest
17
+ def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
18
+ event_stream:, write_skill: nil, delete_skill: nil, set_skill_agents: nil)
19
+ @harvest_store = harvest_store
20
+ @profiles = ProfileSource.coerce(profile_source)
21
+ @event_stream = event_stream
22
+ @write_skill = write_skill ||
23
+ Insika::Commands::WriteSkill.new(skill_store: skill_store,
24
+ skill_catalog: skill_catalog,
25
+ event_stream: event_stream)
26
+ @delete_skill = delete_skill ||
27
+ Insika::Commands::DeleteSkill.new(skill_store: skill_store,
28
+ skill_catalog: skill_catalog,
29
+ event_stream: event_stream)
30
+ @set_skill_agents = set_skill_agents ||
31
+ Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
32
+ event_stream: event_stream)
33
+ end
34
+
35
+ # payload: { snapshot_ref:, operator?, reason? }
36
+ # -> Promotion (the row with rolled_back_at stamped)
37
+ def call(command)
38
+ p = AgentPayload.symbolize(command.payload)
39
+ snapshot_ref = AgentPayload.presence(p[:snapshot_ref])
40
+ raise Insika::ValidationError, "snapshot_ref is required" if snapshot_ref.nil?
41
+
42
+ snap = @harvest_store.find_snapshot(snapshot_ref) ||
43
+ (raise Insika::NotFoundError, "harvest snapshot not found: #{snapshot_ref}")
44
+ # The log row whose snapshot_ref matches — but a promotion that never
45
+ # recorded its row (a crash between the writes and the append) rolls
46
+ # back by snapshot, and the rollback stamp catches up on whatever row
47
+ # exists (D8's honest price, resolved by reference).
48
+ promotion = promotion_for_snapshot(snap)
49
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
50
+
51
+ if snap.existed
52
+ @write_skill.call(Insika::Command.build(:write_skill,
53
+ { name: snap.skill, content: snap.content,
54
+ agent: snap.agent }))
55
+ else
56
+ begin
57
+ @delete_skill.call(Insika::Command.build(:delete_skill,
58
+ { name: snap.skill, agent: snap.agent }))
59
+ rescue Insika::NotFoundError
60
+ # the desired state (gone) already holds — a snapshot taken for a
61
+ # skill that never landed rolls back cleanly
62
+ end
63
+ end
64
+ @set_skill_agents.call(Insika::Command.build(:set_skill_agents,
65
+ { name: snap.skill,
66
+ agent_ids: snap.enabled_for }))
67
+
68
+ rolled = promotion ? @harvest_store.append_rollback(promotion_id: promotion.id,
69
+ operator: operator, reason: p[:reason]) : nil
70
+ @event_stream.emit(Insika::Event.new(
71
+ type: :skill_rolled_back,
72
+ data: { snapshot_ref: snapshot_ref, skill: snap.skill,
73
+ agent: snap.agent, operator: operator }.compact,
74
+ meta: { at: Time.now.utc.iso8601 }
75
+ ))
76
+ rolled || promotion
77
+ end
78
+
79
+ private
80
+
81
+ def promotion_for_snapshot(snap)
82
+ @harvest_store.promotions.find { |row| row.snapshot_ref == snap.id }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # the ONLY path that writes proposals. Distills ONE session
6
+ # end to end: read the transcript and the memory baseline, ask the model,
7
+ # schema-drop, dedup against the ledger, write proposals, mark the session
8
+ # distilled. Synchronous (it runs on the engine's worker fiber, C5) — it
9
+ # creates no task and no turn (D2: no TaskStore work unit — a crash
10
+ # mid-pass leaves the marker unwritten and the next pass re-scans; a
11
+ # duplicate proposal is filtered by the ledger, never applied).
12
+ class RunDistillation
13
+ DEFAULT_IDLE_HOURS = 6
14
+ DEFAULT_MIN_MESSAGES = 3
15
+ DEFAULT_MAX_PROPOSALS = 10
16
+
17
+ def initialize(profiles:, proposal_store:, session_store:, memory_store:,
18
+ settings_store:, event_stream:, distiller_factory: nil)
19
+ @profiles = profiles
20
+ @proposal_store = proposal_store
21
+ @session_store = session_store
22
+ @memory_store = memory_store
23
+ @settings_store = settings_store
24
+ @event_stream = event_stream
25
+ @distiller_factory = distiller_factory ||
26
+ ->(config) { Distill::DistillerFactory.build(config, utility_model: utility_model) }
27
+ end
28
+
29
+ # payload: { session_id: } (agent resolved from the session, D6-bis)
30
+ # -> { distilled: true, proposals: N, dropped: {...}, deduped: N, cost: {...} | nil }
31
+ # | { distilled: false, skipped: "already|untagged|no_agent|disabled|
32
+ # too_fresh|too_short|no_model" }
33
+ def call(command)
34
+ session_id = Coercion.presence(command.payload[:session_id] || command.payload["session_id"])
35
+ raise ValidationError, "session_id is required" if session_id.nil?
36
+
37
+ session = @session_store.find(session_id)
38
+ raise Insika::NotFoundError, "session not found: #{session_id}" if session.nil?
39
+
40
+ return skip("already") if @proposal_store.distilled?(session_id)
41
+
42
+ customer = Coercion.presence(session.vars["customer"])
43
+ return skip("untagged") if customer.nil?
44
+
45
+ agent_id = Coercion.presence(session.vars["agent"])
46
+ return skip("no_agent") if agent_id.nil?
47
+
48
+ profile = @profiles[agent_id]
49
+ return skip("no_agent") if profile.nil?
50
+
51
+ config = Coercion.deep_stringify(profile.distill)
52
+ return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
53
+
54
+ # D4: the pack's own idle threshold wins over the scan's lower bound —
55
+ # a pack that wants 12 h is never distilled at 6.
56
+ idle_hours = config["idle_hours"].to_i
57
+ idle_hours = DEFAULT_IDLE_HOURS unless idle_hours.positive?
58
+ return skip("too_fresh") unless idle?(session.updated_at, idle_hours)
59
+
60
+ min_messages = config["min_messages"].to_i
61
+ min_messages = DEFAULT_MIN_MESSAGES unless min_messages.positive?
62
+ return skip("too_short") if session.messages.size < min_messages
63
+
64
+ tenant = tenant_of(session_id)
65
+ distiller = @distiller_factory.call(config)
66
+ return skip("no_model") if distiller.nil?
67
+
68
+ baseline = memory_baseline(tenant, customer)
69
+ prompt = build_prompt(config, session, baseline)
70
+ result = distiller.distill(prompt: prompt, message_count: session.messages.size,
71
+ max_proposals: (config["max_proposals"] || DEFAULT_MAX_PROPOSALS).to_i)
72
+
73
+ deduped = 0
74
+ survivors = result[:proposals].reject do |proposal|
75
+ dedup = deduped?(tenant, customer, proposal, baseline)
76
+ deduped += 1 if dedup
77
+ dedup
78
+ end
79
+
80
+ survivors.each do |proposal|
81
+ expected = baseline[proposal["name"]]
82
+ @proposal_store.create(
83
+ tenant: tenant, customer: customer, session_ref: session_id,
84
+ key: proposal["name"], value: proposal["value"],
85
+ confidence: proposal["confidence"], evidence: proposal["turns"],
86
+ expected_revision: expected && expected.updated_at,
87
+ expected_existed: !expected.nil?
88
+ )
89
+ end
90
+
91
+ @proposal_store.mark_distilled(session_id, agent: agent_id,
92
+ proposals: survivors.size,
93
+ dropped: result[:dropped],
94
+ deduped: deduped,
95
+ cost: result[:cost])
96
+ @event_stream.emit(Insika::Event.new(
97
+ type: :distillation_completed,
98
+ # counts and ids only (D7) — a fact value never
99
+ # enters the stream.
100
+ data: { session_ref: session_id, agent: agent_id,
101
+ proposals: survivors.size,
102
+ dropped: result[:dropped],
103
+ deduped: deduped,
104
+ cost: result[:cost] },
105
+ meta: { at: Time.now.utc.iso8601 }
106
+ ))
107
+ { distilled: true, proposals: survivors.size, dropped: result[:dropped],
108
+ deduped: deduped, cost: result[:cost] }
109
+ end
110
+
111
+ private
112
+
113
+ # nil tenant in single-tenant deployments; the "<tenant>:" prefix of the
114
+ # session id otherwise (D1 — the same reading ForgetCustomer uses).
115
+ def tenant_of(session_id)
116
+ tenant, rest = session_id.to_s.split(":", 2)
117
+ rest.nil? ? nil : tenant
118
+ end
119
+
120
+ def skip(reason)
121
+ { distilled: false, skipped: reason }
122
+ end
123
+
124
+ def idle?(updated_at, idle_hours)
125
+ return false if Coercion.blank?(updated_at)
126
+
127
+ Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
128
+ rescue ArgumentError
129
+ false
130
+ end
131
+
132
+ # Per fact key: the existence + updated_at at distill time (D5 — the CAS
133
+ # baseline the approval compares against). Sorted by key for a stable
134
+ # prompt.
135
+ def memory_baseline(tenant, customer)
136
+ @memory_store.facts(tenant: tenant, customer: customer)
137
+ .each_with_object({}) { |f, acc| acc[f.key] = f }
138
+ end
139
+
140
+ def deduped?(tenant, customer, proposal, baseline)
141
+ key = proposal["name"].to_s
142
+ value = proposal["value"].to_s
143
+ return true if @proposal_store.decided?(tenant: tenant, customer: customer,
144
+ key: key, value: value)
145
+ return true if @proposal_store.open_pending?(tenant: tenant, customer: customer, key: key)
146
+
147
+ applied = baseline[key]
148
+ !applied.nil? && applied.value == value && applied.origin.to_s.start_with?("distilled:")
149
+ end
150
+
151
+ # The prompt gets the transcript slice (masked through the
152
+ # output filter first — the redaction rule), the
153
+ # customer's CURRENT facts (so the model can avoid re-proposing applied
154
+ # facts), and the answer rules (the pack prompt or DEFAULT_PROMPT).
155
+ def build_prompt(config, session, baseline)
156
+ base = Coercion.presence(config["prompt"]) || Distill::DEFAULT_PROMPT
157
+ transcript = render_transcript(session.messages)
158
+ facts = baseline.values.map { |f| "- #{f.key}: #{f.value}" }
159
+ <<~PROMPT
160
+ #{base.rstrip}
161
+
162
+ ## The conversation
163
+
164
+ #{transcript}
165
+
166
+ ## Facts already in this customer's memory (do not re-propose them)
167
+
168
+ #{facts.empty? ? "(none)" : facts.join("\n")}
169
+ PROMPT
170
+ end
171
+
172
+ def render_transcript(messages)
173
+ redacted, = Insika::Safety::Detectors.redact(
174
+ messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
175
+ )
176
+ redacted
177
+ end
178
+
179
+ def utility_model
180
+ return nil unless @settings_store
181
+
182
+ @settings_store.get["utility_model"]
183
+ end
184
+ end
185
+ end
186
+ end