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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # the durable CONTACT STATE cell per (tenant, customer)— ONE
7
+ # derived cell, never a transition log (D2): `granted | revoked |
8
+ # unavailable`, the sends-without-reply counter and the last outbound
9
+ # timestamp. Dumb domain store: it holds no policy and no follow-up records
10
+ # (the firer and the inbound hook own those transformations).
11
+ #
12
+ # Invariants (the firer enforces them, the store only records):
13
+ # · only `granted` may be messaged; absent = never messaged (:consent block);
14
+ # · `revoked` is immediate and permanent until the customer speaks;
15
+ # · `unavailable` means silence ≠ refusal — set when sends_without_reply
16
+ # reaches the policy's ceiling; further fires block until the customer
17
+ # speaks; any customer message reopens (sets granted + zeroes the counter).
18
+ #
19
+ # The customer identifier is the SAME string the message contract carries
20
+ # (`customer:` on /v1/messages, WS8).
21
+ class ContactStore
22
+ SCOPE = "contacts"
23
+ STATES = %w[granted revoked unavailable].freeze
24
+
25
+ Cell = Data.define(:state, :sends_without_reply, :last_outbound_at, :updated_at)
26
+
27
+ def initialize(store:)
28
+ @store = store
29
+ end
30
+
31
+ # -> [Cell] | nil (absent = never messaged — the :consent block). nil
32
+ # customer -> nil (an untagged conversation has no cell).
33
+ def get(tenant:, customer:)
34
+ return nil if customer.to_s.empty?
35
+
36
+ record = @store.get(SCOPE, key(tenant, customer))
37
+ record && to_cell(record)
38
+ end
39
+
40
+ # -> { "<tenant>:<customer>" => raw record } — the whole scope, for the
41
+ # doctor's contact summary and the Studio (read-only folds; the mutations
42
+ # go through the commands, D10).
43
+ def cells
44
+ @store.list(SCOPE).each_with_object({}) do |k, acc|
45
+ record = @store.get(SCOPE, k)
46
+ acc[k] = record if record
47
+ end
48
+ end
49
+
50
+ # The ONLY writers. Each is a read-check-write on the cell (#transaction —
51
+ # two processes flipping the state in the same second serialize).
52
+
53
+ # Any customer message reopens the conversation: `granted` + the counter
54
+ # reset. The consent record itself (D2/D7) is the `schedule` tool call.
55
+ def set_granted(tenant:, customer:, now: Time.now.utc)
56
+ write(tenant, customer, now) do |record|
57
+ record["state"] = "granted"
58
+ record["sends_without_reply"] = 0
59
+ end
60
+ end
61
+
62
+ # D7: the schedule tool's consent write. The customer agreeing
63
+ # in-conversation IS the consent — but ONLY a customer message reopens
64
+ # (D2): this NEVER lifts `:unavailable` and NEVER resets
65
+ # sends_without_reply, so a re-booking inside the scheduled turn cannot
66
+ # clear the silence protection. Creates the cell when absent (the first
67
+ # consent). Raises Insika::ValidationError on `:revoked` — an opt-out is
68
+ # permanent; the caller refuses.
69
+ def consent(tenant:, customer:, now: Time.now.utc)
70
+ raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
71
+
72
+ @store.transaction do
73
+ record = @store.get(SCOPE, key(tenant, customer)) ||
74
+ { "state" => "granted", "sends_without_reply" => 0,
75
+ "last_outbound_at" => nil, "updated_at" => nil }
76
+ if record["state"] == "revoked"
77
+ raise Insika::ValidationError,
78
+ "this customer opted out — you cannot schedule a follow-up"
79
+ end
80
+
81
+ record["state"] ||= "granted"
82
+ record["updated_at"] = now.iso8601
83
+ @store.set(SCOPE, key(tenant, customer), record)
84
+ to_cell(record)
85
+ end
86
+ end
87
+
88
+ # Keyword / channel opt-out / operator: immediate and permanent until the
89
+ # customer speaks. Nothing auto-revokes.
90
+ def set_revoked(tenant:, customer:, now: Time.now.utc)
91
+ write(tenant, customer, now) do |record|
92
+ record["state"] = "revoked"
93
+ end
94
+ end
95
+
96
+ # Silence reached the policy's ceiling (the firer counts sends without a
97
+ # reply); further fires block until the customer speaks.
98
+ def mark_unavailable(tenant:, customer:, now: Time.now.utc)
99
+ write(tenant, customer, now) do |record|
100
+ record["state"] = "unavailable"
101
+ end
102
+ end
103
+
104
+ # The firer's call: sends_without_reply += 1, last_outbound_at = now.
105
+ # Creates the cell when absent (granted — the firer checks the GO before
106
+ # bumping, so a bump only happens after the consent gate).
107
+ def bump_outbound(tenant:, customer:, now: Time.now.utc)
108
+ write(tenant, customer, now) do |record|
109
+ record["state"] ||= "granted"
110
+ record["sends_without_reply"] = record["sends_without_reply"].to_i + 1
111
+ record["last_outbound_at"] = now.iso8601
112
+ end
113
+ end
114
+
115
+ # Purge paths (C11 — the LGPD footprint): one cell; a whole tenant's
116
+ # (prefix scan); age-based. All nil-safe.
117
+
118
+ # -> true | false (did the cell exist?)
119
+ def delete(tenant:, customer:)
120
+ return false if customer.to_s.empty?
121
+
122
+ @store.delete(SCOPE, key(tenant, customer))
123
+ end
124
+
125
+ # -> count removed.
126
+ def purge(tenant:)
127
+ prefix = "#{tenant_id(tenant)}:"
128
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
129
+ keys.each { |k| @store.delete(SCOPE, k) }
130
+ keys.size
131
+ end
132
+
133
+ # Cells untouched past the cutoff (WS8 retention). -> count removed.
134
+ def delete_older_than(time)
135
+ cutoff = time.utc.iso8601
136
+ removed = 0
137
+ @store.list(SCOPE).each do |k|
138
+ record = @store.get(SCOPE, k)
139
+ next unless record && record["updated_at"].to_s < cutoff
140
+
141
+ @store.delete(SCOPE, k)
142
+ removed += 1
143
+ end
144
+ removed
145
+ end
146
+
147
+ private
148
+
149
+ # One cell per (tenant, customer); blank tenant -> the literal "platform"
150
+ # (outcome_store.rb's rule — contact, follow-up and outcome keys share one
151
+ # tenant segment so the purge prefix scans line up).
152
+ def key(tenant, customer)
153
+ "#{tenant_id(tenant)}:#{customer}"
154
+ end
155
+
156
+ def tenant_id(tenant)
157
+ t = tenant.to_s
158
+ t.empty? ? "platform" : t
159
+ end
160
+
161
+ # Read-check-write inside the backend transaction (the budget_ledger.rb:38
162
+ # discipline): two writers racing the same cell serialize on the backend
163
+ # lock and the loser re-reads.
164
+ def write(tenant, customer, now, &block)
165
+ raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
166
+
167
+ @store.transaction do
168
+ record = @store.get(SCOPE, key(tenant, customer)) ||
169
+ { "state" => "granted", "sends_without_reply" => 0,
170
+ "last_outbound_at" => nil, "updated_at" => nil }
171
+ block.call(record)
172
+ record["updated_at"] = now.iso8601
173
+ @store.set(SCOPE, key(tenant, customer), record)
174
+ to_cell(record)
175
+ end
176
+ end
177
+
178
+ def to_cell(record)
179
+ Cell.new(state: record["state"], sends_without_reply: record["sends_without_reply"].to_i,
180
+ last_outbound_at: record["last_outbound_at"], updated_at: record["updated_at"])
181
+ end
182
+ end
183
+ end
@@ -66,7 +66,14 @@ module Insika
66
66
 
67
67
  fragments = []
68
68
  tasks.each do |provider, child|
69
- fragments.concat(Array(child.wait))
69
+ # stamp the layer at PRODUCTION from the PROVIDER's
70
+ # declaration — authoritative, so a fragment that arrives pre-stamped
71
+ # cannot sneak above the boundary (a :volatile provider emitting a
72
+ # fragment with layer :identity would otherwise bypass the doctor's
73
+ # class-level check). A custom provider that never learned the contract
74
+ # leaves :volatile.
75
+ layer = provider.respond_to?(:layer) ? provider.layer : :volatile
76
+ Array(child.wait).each { |f| fragments << f.with(layer: layer) }
70
77
  rescue StandardError => e # Async::TimeoutError is a StandardError; Async::Stop is NOT (propagates)
71
78
  handle_provider_failure(provider, e, request)
72
79
  end
@@ -135,10 +142,14 @@ module Insika
135
142
  [survivors, evicted_sources]
136
143
  end
137
144
 
138
- # Step 6: assembly in DETERMINISTIC canonical order.
145
+ # Step 6: assembly in DETERMINISTIC canonical order. The
146
+ # identity-first partition — nothing volatile renders above the cache
147
+ # boundary. Each partition keeps the existing canonical sort; the partition
148
+ # is by LAYER only.
139
149
  def assemble(fragments, cap, evicted)
140
150
  system_frags = fragments.select { |f| f.placement == :system }
141
- .sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
151
+ identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
152
+ system_frags = sort_canonical(identity) + sort_canonical(volatile)
142
153
  history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
143
154
  tool_frags = fragments.select { |f| f.placement == :tool_context }
144
155
 
@@ -153,6 +164,13 @@ module Insika
153
164
  )
154
165
  end
155
166
 
167
+ # The canonical system sort (priority DESC, source ASC, production index) —
168
+ # applied WITHIN each layer partition, so the prior order survives inside
169
+ # it and the boundary is the only thing that moved.
170
+ def sort_canonical(frags)
171
+ frags.sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
172
+ end
173
+
156
174
  # :provider_warning. The Builder does not know task_id/seq (correlation is
157
175
  # the Executor's job) — emits with what it has; Event#to_h does meta.compact.
158
176
  def emit_warning(provider_id, message, request)
@@ -8,6 +8,10 @@ module Insika
8
8
  # tokens: Integer | nil; estimated by the Builder when nil
9
9
  # source: String — provider id (audit)
10
10
  # pinned: true -> uncuttable in the budget (e.g. identity)
11
+ # layer: :identity | :volatile | nil. Stamped by the Builder
12
+ # at production from the provider's declaration; nil (a fragment
13
+ # built outside the Builder) reads as :volatile everywhere it is
14
+ # consumed — parity, never a crash.
11
15
  # labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
12
16
  # as ids. Content-FREE by contract, so the context trace can report
13
17
  # which skills a turn injected without storing a byte of the bodies.
@@ -22,12 +26,12 @@ module Insika
22
26
  # written to the context trace and to events as JSON: the round-trip
23
27
  # is then the identity, and no reader has to defend against both.
24
28
  ContextFragment = Data.define(:content, :placement, :priority, :tokens,
25
- :source, :pinned, :labels) do
29
+ :source, :pinned, :labels, :layer) do
26
30
  def self.build(content:, placement:, source:, priority: 50, tokens: nil,
27
- pinned: false, labels: [])
31
+ pinned: false, labels: [], layer: nil)
28
32
  new(content: content, placement: placement, priority: priority,
29
33
  tokens: tokens, source: source, pinned: pinned,
30
- labels: Array(labels).map { |l| label(l) })
34
+ labels: Array(labels).map { |l| label(l) }, layer: layer)
31
35
  end
32
36
 
33
37
  # A bare String is still a valid label (a provider that has an id but no reason
@@ -22,6 +22,9 @@ module Insika
22
22
  SKILL = 80 # <available_skills> level 1 (Skill)
23
23
  MEMORY = 75 # <memory> read path (Memory)
24
24
  TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
25
+ BRIEFING = 65 # <briefing> session working state (Briefing) — D5:
26
+ # below every identity/skill/memory block (never breaks the
27
+ # pinned prefix), above the turn's own <request_context>.
25
28
  HISTORY_MAX = 79 # history ceiling by recency (Session)
26
29
  HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
27
30
  REQUEST = 40 # <request_context> — turn injection, the most cuttable
@@ -9,11 +9,25 @@ module Insika
9
9
  def required? = false # true -> failure aborts the turn
10
10
  def enabled_for?(_profile) = true
11
11
  def call(_request) = [] # -> [ContextFragment]; may do IO
12
+ # which cache layer the output belongs to.
13
+ # :identity -> changes only on deploy/config edit (the cacheable prefix);
14
+ # :volatile -> may change per turn (the Builder renders identity first).
15
+ # :volatile is the conservative default — nothing gets pinned by accident.
16
+ def layer = :volatile
12
17
  end
13
18
 
14
19
  # Input for the provider contract.
15
- # session: SessionStore::Session | nil
16
- # checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
20
+ # session: SessionStore::Session | nil
21
+ # checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
22
+ # memory_scope: the CUSTOMER-scoped memory cell (WS8): "[tenant:]customer"
23
+ # when the request carries a customer, else nil (the providers
24
+ # fall back to tenant || session). Kept separate from `tenant`
25
+ # (the <request_context> merchant label) on purpose.
17
26
  ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
18
- :checkpoint)
27
+ :checkpoint, :memory_scope) do
28
+ def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
29
+ checkpoint: nil, memory_scope: nil)
30
+ super
31
+ end
32
+ end
19
33
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Read path for the session briefing: the per-session
7
+ # working-state the agent keeps and asks for. Thin adapter over the
8
+ # SessionStore, same pattern as Memory: one `:system` fragment,
9
+ # deterministic. The MISSING list is rendered, never implied — that list
10
+ # is what stops the model re-asking for a field already given.
11
+ class Briefing < ContextProvider
12
+ def initialize(session_store:)
13
+ @session_store = session_store
14
+ end
15
+
16
+ # Stable id -> the context-trace category "briefing".
17
+ def id = "briefing"
18
+
19
+ # Pack-declared: no briefing_fields -> no provider (the Builder still
20
+ # applies the `context_providers` allowlist on top — two gates, like Memory).
21
+ def enabled_for?(profile)
22
+ fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
23
+ !Array(fields).empty?
24
+ end
25
+
26
+ # required? == false (default): a store failure degrades via the Builder's
27
+ # warning path, never aborts the turn.
28
+ def call(request)
29
+ session = request.respond_to?(:session) ? request.session : nil
30
+ return [] if session.nil? # one-shot turns have no briefing
31
+
32
+ briefing = briefing_for(session)
33
+ fields = briefing["fields"] || {}
34
+ declared = Array(request.profile.briefing_fields)
35
+ return [] if declared.empty? # defensive; enabled_for? already gates
36
+
37
+ block = format_block(declared, fields, briefing["next_step"])
38
+ return [] if block.nil?
39
+
40
+ [ContextFragment.build(content: block, placement: :system,
41
+ priority: Context::Priority::BRIEFING,
42
+ source: id)]
43
+ end
44
+
45
+ private
46
+
47
+ # Re-reads the briefing from the store, like the Session provider: the
48
+ # persisted record is the source of truth, not the request's turn-start
49
+ # snapshot. A read failure propagates to the Builder, which degrades it
50
+ # to a :provider_warning (required? == false).
51
+ def briefing_for(session)
52
+ @session_store.find(session.id)&.briefing || {}
53
+ end
54
+
55
+ # Byte contract (the specs assert this shape):
56
+ # <briefing>
57
+ # known:
58
+ # size: M
59
+ # still missing: delivery_day
60
+ # next step: send the payment link tomorrow at 10
61
+ # </briefing>
62
+ # Rules: `known` renders only when at least one declared field has a
63
+ # stored value; `still missing` renders every declared field with no
64
+ # stored value (including the all-missing case — that is the block's
65
+ # job); `next step` renders only when non-nil; stored keys NOT in the
66
+ # declaration are never rendered (they stay in the store and reappear if
67
+ # the pack re-declares them).
68
+ def format_block(declared, fields, next_step)
69
+ known = declared.filter_map do |name|
70
+ " #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
71
+ end
72
+ missing = declared.reject { |name| Coercion.present?(fields[name]) }
73
+
74
+ lines = []
75
+ lines << "known:" unless known.empty?
76
+ lines.concat(known)
77
+ lines << "still missing: #{missing.join(', ')}" unless missing.empty?
78
+ lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
79
+ return nil if lines.empty?
80
+
81
+ <<~BLOCK.strip
82
+ <briefing>
83
+ #{lines.join("\n")}
84
+ </briefing>
85
+ BLOCK
86
+ end
87
+
88
+ # utf8 the value and flatten newlines/whitespace so a value can never
89
+ # break the block's line structure.
90
+ def flatten(value)
91
+ Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -20,7 +20,7 @@ module Insika
20
20
  # required? == false (default): a failure (store unavailable) becomes a
21
21
  # :provider_warning + graceful degradation — never aborts the turn.
22
22
  def call(request)
23
- tenant = memory_tenant(request)
23
+ tenant = memory_scope(request)
24
24
  facts = @store.facts(tenant: tenant)
25
25
  notes = @store.notes(tenant: tenant, limit: @notes_limit)
26
26
  return [] if facts.empty? && notes.empty?
@@ -33,15 +33,24 @@ module Insika
33
33
 
34
34
  private
35
35
 
36
- # Engine memory scope: an EXPLICIT tenant from the Command wins
37
- # (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
38
- # memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
39
- # applies _default). Symmetric to the write path (`state.tenant` in the Executor).
40
- def memory_tenant(request)
36
+ # Engine memory scope (WS8): the request's CUSTOMER-scoped cell
37
+ # ("[tenant:]customer" — engine-owner memory is per customer, never per
38
+ # tenant) wins; otherwise an EXPLICIT tenant from the Command (the
39
+ # multi-merchant override); otherwise the SESSION (=chat), MARKED like
40
+ # the write path ("chat:<session id>" — : a session cell is
41
+ # never a bare cell, so the drill cannot read a conversation as a
42
+ # customer). No session (one-shot) and no tenant -> nil (MemoryStore
43
+ # applies _default). Symmetric to the write path (`state.tenant` in the
44
+ # Executor).
45
+ def memory_scope(request)
46
+ scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
47
+ return scoped if scoped
48
+
41
49
  explicit = request.respond_to?(:tenant) ? request.tenant : nil
42
50
  return explicit if explicit
43
51
 
44
- request.respond_to?(:session) ? request.session&.id : nil
52
+ session = request.respond_to?(:session) ? request.session : nil
53
+ session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
45
54
  end
46
55
 
47
56
  # Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
@@ -10,11 +10,26 @@ module Insika
10
10
  #
11
11
  # PER-AGENT identity. `profile.prompt_files` (file names)
12
12
  # wins over the wiring's `files:` — this fixes the limitation of a new
13
- # agent inheriting Bia's prompt. The content comes from `agent_files`
13
+ # agent inheriting the previous persona's prompt. The content comes from `agent_files`
14
14
  # (AgentFileStore, lives in the Store), with a File.read fallback
15
15
  # for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
16
16
  # `files:` (deployment default; byte-for-byte parity).
17
17
  class Prompt < ContextProvider
18
+ # Engine-owned execution discipline, appended AFTER the agent's identity.
19
+ # The one behavior every reference harness bakes into its base prompt
20
+ # (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
21
+ # result read as final. A constant — byte-identical every turn, so
22
+ # prompt_caching pays ONE write on the deploy that introduces it, never
23
+ # per turn. Opt-out per profile (`tool_persistence false`), the single
24
+ # default-ON profile flag: the proven-good behavior is the default, the
25
+ # exception is the thing an operator declares.
26
+ TOOL_PERSISTENCE = "## Tool discipline\n" \
27
+ "- Weak or empty tool result: try again with a different approach — rephrase the " \
28
+ "query, use a synonym or broader term, drop a secondary filter — before telling " \
29
+ "the user you found nothing. Do not narrate the retries. Then conclude.\n" \
30
+ "- Tool error: read the error, fix the arguments or try another path; never " \
31
+ "repeat the exact same call."
32
+
18
33
  def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
19
34
  @base = base
20
35
  @files = Array(files)
@@ -24,6 +39,8 @@ module Insika
24
39
  end
25
40
 
26
41
  def required? = true
42
+ # identity (config/agent-file derived — already pinned).
43
+ def layer = :identity
27
44
 
28
45
  def call(request)
29
46
  fragments = []
@@ -62,7 +79,18 @@ module Insika
62
79
  else
63
80
  sources.each { |src| parts << read_source(profile&.id, src.to_s) }
64
81
  end
65
- parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
82
+ identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
83
+ # Discipline rides an EXISTING identity, never substitutes one: an
84
+ # agent with no identity at all must stay detectably empty.
85
+ return identity if identity.empty? || !tool_persistence?(profile)
86
+
87
+ "#{identity}\n\n#{TOOL_PERSISTENCE}"
88
+ end
89
+
90
+ # nil/absent/true = ON (the engine default); only an explicit `false`
91
+ # turns it off. Defensive respond_to?: a minimal profile stub reads ON.
92
+ def tool_persistence?(profile)
93
+ !(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
66
94
  end
67
95
 
68
96
  # GLOBAL system files: apply to every agent,
@@ -10,6 +10,8 @@ module Insika
10
10
  class Skill < CatalogProvider
11
11
  # priority 80: above deferred tools (70), below pinned identity.
12
12
  def priority = Context::Priority::SKILL
13
+ # catalog + allowlist — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
@@ -10,6 +10,8 @@ module Insika
10
10
  class ToolSearch < CatalogProvider
11
11
  # priority 70: below skills (80) in the sacrifice order.
12
12
  def priority = Context::Priority::TOOL_SEARCH
13
+ # tool registry + tools_deferred — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
@@ -24,7 +24,8 @@ module Insika
24
24
  # suspends (approval) and resumes re-runs the context stage, and the
25
25
  # re-record replaces the first one instead of duplicating it. (`turn` is
26
26
  # 1-based PER TASK, so the task is part of the key.) Missing session_id ->
27
- # no-op.
27
+ # no-op. -> the sanitized entry (parks it on TurnState for the
28
+ # stage-8 cache merge).
28
29
  def record(session_id:, entry:)
29
30
  sid = session_id.to_s
30
31
  return if sid.empty?
@@ -33,6 +34,7 @@ module Insika
33
34
  key = [e["task_id"], e["turn"]]
34
35
  list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
35
36
  @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
37
+ e
36
38
  rescue StandardError
37
39
  nil
38
40
  end
@@ -63,6 +65,11 @@ module Insika
63
65
  cat = { "tokens" => int(c[:tokens] || c["tokens"]),
64
66
  "fragments" => int(c[:fragments] || c["fragments"]),
65
67
  "pinned" => int(c[:pinned] || c["pinned"]) }
68
+ # which cache layer the category belongs to ("identity" |
69
+ # "volatile"). Absent for a category recorded before the contract (or
70
+ # one that never learned it) — the view guards on nil.
71
+ layer = c[:layer] || c["layer"]
72
+ cat["layer"] = layer.to_s if layer
66
73
  # WHAT the category carried and WHY ({name, reason}) — still ids only, so
67
74
  # the no-masking-needed contract above holds. Omitted when empty: most
68
75
  # categories have nothing to name and an empty key is just noise.
@@ -71,8 +78,36 @@ module Insika
71
78
  acc[name.to_s] = cat
72
79
  end,
73
80
  "tools" => { "count" => int(tools[:count] || tools["count"]),
74
- "tokens" => int(tools[:tokens] || tools["tokens"]) }
81
+ "tokens" => int(tools[:tokens] || tools["tokens"]) },
82
+ "fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
83
+ "cache" => cache_of(e[:cache] || e["cache"])
84
+ }.compact
85
+ end
86
+
87
+ # { name => sha256-hex }; names stringified, non-strings
88
+ # dropped. Absent when the caller passed nothing (a trace recorded before
89
+ # this feature has no key and the view guards on nil).
90
+ def fingerprints_of(raw)
91
+ return nil unless raw.is_a?(Hash) && !raw.empty?
92
+
93
+ raw.each_with_object({}) do |(name, hex), acc|
94
+ acc[name.to_s] = hex.to_s if hex.is_a?(String)
95
+ end.then { |h| h.empty? ? nil : h }
96
+ end
97
+
98
+ # { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
99
+ # Unknown keys dropped. Present only when the caller passed it.
100
+ def cache_of(raw)
101
+ return nil unless raw.is_a?(Hash)
102
+
103
+ c = {
104
+ "hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
105
+ "cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
106
+ "prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
107
+ "invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
75
108
  }
109
+ c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
110
+ c
76
111
  end
77
112
 
78
113
  # Labels are {name, reason} in string keys (ContextFragment.label). A bare string
@@ -88,5 +123,6 @@ module Insika
88
123
  end
89
124
 
90
125
  def int(value) = Integer(value || 0)
126
+ def int_or_nil(value) = value.nil? ? nil : Integer(value)
91
127
  end
92
128
  end