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
@@ -10,6 +10,14 @@ module Insika
10
10
  # `PendingActionStore` (normalizes symbol→string on write, O(n) scan on
11
11
  # read, records with a timestamp).
12
12
  #
13
+ # the record gains `origin`/`created_at`/`expires_at`, the
14
+ # (tenant, customer) pair becomes first-class at the API (`customer:`
15
+ # builds the same "memory:<tenant>:<customer>" scope the Executor derives),
16
+ # and the store enumerates its cells (the Studio drill, the doctor, the
17
+ # retention sweep). Migration is lazy: tolerant reads, materialized on the
18
+ # first write. The audit trail lives in `MemoryAuditStore` (C2), NOT here —
19
+ # this stays a dumb domain store.
20
+ #
13
21
  # NOT to be confused with `Insika::Stores::Memory` (in-memory KV backend):
14
22
  # this is the domain store; that one is one of the backends this writes to.
15
23
  class MemoryStore
@@ -18,52 +26,246 @@ module Insika
18
26
  NOTE_PREFIX = "note:"
19
27
  DEFAULT_TENANT = "_default" # no tenant in the Command
20
28
 
21
- Fact = Data.define(:key, :value, :updated_at)
29
+ # the record's provenance. A closed string set: "legacy"
30
+ # (migrated, unknown writer), "engine" (the remember tool / an
31
+ # integration write), "operator" (Studio edit), "distilled"
32
+ # reserved).
33
+ ORIGIN_LEGACY = "legacy"
34
+
35
+ # the per-SESSION cell marker. Engine-owner memory with no
36
+ # customer and no explicit tenant falls back to the session (executor
37
+ # parity) — the cell is MARKED "memory:chat:<session id>" so the drill and
38
+ # the doctor never read a conversation as a customer (a bare
39
+ # "memory:<session id>" cell is indistinguishable from a single-tenant
40
+ # customer ref). A real tenant NAMED "chat" would collide with the marker
41
+ # (its customer cells would read as session cells) — accepted, merchant
42
+ # ids live in a different namespace in practice.
43
+ SESSION_TAG = "chat"
44
+
45
+ Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
22
46
  Note = Data.define(:id, :text, :created_at)
23
47
 
24
- def initialize(store:)
48
+ def initialize(store:, clock: nil)
25
49
  @store = store
50
+ @clock = clock # -> Time, injectable for specs
26
51
  end
27
52
 
28
- # Upsert (last-write-wins, Store contract). -> Fact
29
- def put_fact(tenant:, key:, value:)
30
- record = { "key" => key.to_s, "value" => stringify(value), "updated_at" => timestamp }
31
- @store.set(scope_for(tenant), FACT_PREFIX + key.to_s, record)
53
+ # Upsert (last-write-wins, Store contract). `customer:` PRESENT moves the
54
+ # scope to the [tenant:]customer cell (WS8's rule — nil tenant + customer
55
+ # -> bare "memory:<customer>", NEVER _default). `origin` defaults to
56
+ # "engine" (the remember tool's write). `expires_at` (ISO8601 String) is
57
+ # normalized and validated here — an invalid value raises ValidationError
58
+ # (a silently dropped LGPD expiry date is the defect). Preserves the
59
+ # existing record's created_at; rewrites a legacy record in the full
60
+ # shape. -> Fact
61
+ def put_fact(tenant:, key:, value:, customer: nil, origin: "engine", expires_at: nil)
62
+ scope = scope_for(tenant, customer)
63
+ current = @store.get(scope, FACT_PREFIX + key.to_s)
64
+ prev = current.is_a?(Hash) ? current : {}
65
+ stamp = timestamp
66
+ record = {
67
+ "key" => key.to_s, "value" => stringify(value),
68
+ "origin" => Coercion.presence(origin) || "engine",
69
+ "created_at" => Coercion.presence(prev["created_at"]) || Coercion.presence(prev["updated_at"]) || stamp,
70
+ "updated_at" => stamp,
71
+ "expires_at" => normalize_expiry(expires_at)
72
+ }
73
+ @store.set(scope, FACT_PREFIX + key.to_s, record)
32
74
  to_fact(record)
33
75
  end
34
76
 
35
- # -> Fact | nil
36
- def get_fact(tenant:, key:)
37
- record = @store.get(scope_for(tenant), FACT_PREFIX + key.to_s)
77
+ # -> Fact | nil (tolerant read: missing origin -> "legacy", created_at ->
78
+ # updated_at, expires_at -> nil).
79
+ def get_fact(tenant:, key:, customer: nil)
80
+ record = @store.get(scope_for(tenant, customer), FACT_PREFIX + key.to_s)
38
81
  record && to_fact(record)
39
82
  end
40
83
 
41
- # -> [Fact] sorted by key (list is lexicographic).
42
- def facts(tenant:)
43
- scope = scope_for(tenant)
84
+ # -> [Fact] sorted by key. Facts whose expires_at <= now are EXCLUDED
85
+ # (D5: an expired fact is never injected nor shown, even before the
86
+ # sweep runs).
87
+ def facts(tenant:, customer: nil)
88
+ scope = scope_for(tenant, customer)
44
89
  @store.list(scope, FACT_PREFIX).filter_map do |k|
45
90
  record = @store.get(scope, k)
46
- record && to_fact(record)
91
+ next unless record
92
+
93
+ fact = to_fact(record)
94
+ next if expired?(fact)
95
+
96
+ fact
47
97
  end
48
98
  end
49
99
 
50
100
  # -> bool (did it exist?)
51
- def forget_fact(tenant:, key:)
52
- @store.delete(scope_for(tenant), FACT_PREFIX + key.to_s)
101
+ def forget_fact(tenant:, key:, customer: nil)
102
+ @store.delete(scope_for(tenant, customer), FACT_PREFIX + key.to_s)
103
+ end
104
+
105
+ # CAS write (WS8): only writes when the STORED fact's updated_at still
106
+ # equals the caller's revision — a fact a concurrent writer already moved
107
+ # is refused instead of clobbered (last-write-wins is the default; this is
108
+ # the opt-in optimistic path). -> Fact (written) | nil (lost the race — the
109
+ # caller must re-read and retry). The read-compare-write rides
110
+ # `@store.transaction` (the repo rule) — `next`, never `return`, inside.
111
+ def replace_if_revision(tenant:, key:, value:, expected_revision:,
112
+ customer: nil, origin: "engine", expires_at: nil)
113
+ @store.transaction do
114
+ current = @store.get(scope_for(tenant, customer), FACT_PREFIX + key.to_s)
115
+ next nil if current.nil? || current["updated_at"] != expected_revision
116
+
117
+ put_fact(tenant: tenant, key: key, value: value, customer: customer,
118
+ origin: origin, expires_at: expires_at)
119
+ end
120
+ end
121
+
122
+ # Purges the WHOLE scope (WS8 — forget_customer / delete_tenant_data). The
123
+ # scope string is the isolation boundary: one cell per
124
+ # (tenant-or-customer), so zeroing the cell cannot touch another's.
125
+ # The list-then-delete rides `@store.transaction` (the repo rule): an erasure
126
+ # that is half-applied is the LGPD defect, and a fact written between the
127
+ # list and the deletes would survive a purge that reported success.
128
+ # -> count of records purged.
129
+ def purge(tenant:, customer: nil)
130
+ scope = scope_for(tenant, customer)
131
+ @store.transaction do
132
+ keys = @store.list(scope)
133
+ keys.each { |k| @store.delete(scope, k) }
134
+ keys.size
135
+ end
136
+ end
137
+
138
+ # Purges a TENANT and every customer cell under it (WS8 phase 2 —
139
+ # delete_tenant_data). The tenant's own cell ("memory:<tenant>") plus each
140
+ # cell whose scope starts with "memory:<tenant>:" (the customer cells),
141
+ # plus the tenant's SESSION-marked cells ("memory:chat:<tenant>:*" — in
142
+ # multi-tenant a session id is "<tenant>:<id>", so its memory cell is
143
+ # "memory:chat:<tenant>:<id>"). The scope enumeration is the Store's — no
144
+ # session-derived list, so a customer cell whose session was already
145
+ # deleted is still purged. ONE transaction for the whole tenant (the repo
146
+ # rule): the scope enumeration and every delete see the same snapshot, so a
147
+ # customer cell born mid-purge cannot slip through a deletion that reported
148
+ # success. -> count of records purged.
149
+ def purge_tenant(tenant)
150
+ cell = scope_for(tenant)
151
+ @store.transaction do
152
+ scopes = [cell] + @store.scopes("#{cell}:") +
153
+ @store.scopes("#{SCOPE_PREFIX}:#{SESSION_TAG}:#{tenant}:")
154
+ scopes.sum do |scope|
155
+ keys = @store.list(scope)
156
+ keys.each { |k| @store.delete(scope, k) }
157
+ keys.size
158
+ end
159
+ end
160
+ end
161
+
162
+ # Age-based prune (WS8 retention). New : `scope:` limits the pass
163
+ # to ONE cell; a fact with an explicit expires_at is SKIPPED (D5 — the
164
+ # explicit override owns that fact's life). -> count removed.
165
+ def prune_older_than(time, scope: nil)
166
+ cutoff = time.utc.iso8601
167
+ removed = 0
168
+ scopes = scope ? [scope] : @store.scopes("#{SCOPE_PREFIX}:")
169
+ scopes.each do |sc|
170
+ @store.list(sc).each do |k|
171
+ rec = @store.get(sc, k)
172
+ next if rec && rec["expires_at"] # the override owns this fact's life
173
+
174
+ stamp = rec && (rec["updated_at"] || rec["created_at"])
175
+ next unless stamp && stamp.to_s < cutoff
176
+
177
+ @store.delete(sc, k)
178
+ removed += 1
179
+ end
180
+ end
181
+ removed
182
+ end
183
+
184
+ # Removes facts whose expires_at <= now (default clock), EVERY cell.
185
+ # -> count removed.
186
+ def prune_expired(now = nil)
187
+ cutoff = (now || self.now).utc
188
+ removed = 0
189
+ @store.scopes("#{SCOPE_PREFIX}:").each do |scope|
190
+ @store.list(scope).each do |k|
191
+ rec = @store.get(scope, k)
192
+ next unless rec && rec["expires_at"]
193
+
194
+ begin
195
+ next unless Time.iso8601(rec["expires_at"]) <= cutoff
196
+ rescue ArgumentError
197
+ next
198
+ end
199
+
200
+ @store.delete(scope, k)
201
+ removed += 1
202
+ end
203
+ end
204
+ removed
205
+ end
206
+
207
+ # --- enumeration (the drill / doctor / retention inputs) -----------
208
+
209
+ # -> [{scope:, tenant: String|nil, customer: String|nil}] — every "memory:*"
210
+ # scope, classified by SHAPE (D6). _default -> {tenant: nil, customer: nil}.
211
+ def cells
212
+ @store.scopes("#{SCOPE_PREFIX}:").map { |scope| self.class.parse_cell(scope) }
213
+ end
214
+
215
+ # -> [{scope:, tenant:, customer:}] — the cells that hold CUSTOMER memory:
216
+ # 2+-segment scopes always (except the SESSION-marked cells — a session is
217
+ # never a customer); 1-segment scopes whose name is not _default and not in
218
+ # `reserved` (the caller's agent ids / tenant cells). Sorted by scope.
219
+ def customer_cells(reserved: [])
220
+ excluded = Array(reserved).map(&:to_s)
221
+ cells.reject do |c|
222
+ self.class.session_cell?(c) ||
223
+ (c[:tenant].nil? && (c[:customer].nil? || excluded.include?(c[:customer])))
224
+ end.sort_by { |c| c[:scope] }
225
+ end
226
+
227
+ # Is this a classified cell the engine's per-SESSION cell
228
+ # ("memory:chat:<session id>")? The one classification both the drill and
229
+ # the doctor share — a session-derived cell must never read as a customer.
230
+ def self.session_cell?(cell) = cell[:tenant] == SESSION_TAG
231
+
232
+ # The public cell string for a pair (the commands and the Studio build URLs
233
+ # and audit keys with this — the same string the Executor derives).
234
+ def cell_for(tenant, customer = nil) = scope_for(tenant, customer)
235
+
236
+ # The shared classification (public — the Studio/commands split audit
237
+ # fields): "memory:acme:c-123" -> {scope:, tenant: "acme", customer: "c-123"};
238
+ # "memory:c-123" -> {scope:, tenant: nil, customer: "c-123"};
239
+ # "memory:_default" -> {scope:, tenant: nil, customer: nil}.
240
+ def self.parse_cell(scope)
241
+ rest = scope.to_s.sub(/\A#{SCOPE_PREFIX}:/, "")
242
+ base, tail = rest.split(":", 2)
243
+ if base == DEFAULT_TENANT && tail.nil?
244
+ { scope: scope, tenant: nil, customer: nil }
245
+ elsif tail
246
+ { scope: scope, tenant: base, customer: tail }
247
+ else
248
+ { scope: scope, tenant: nil, customer: base }
249
+ end
53
250
  end
54
251
 
55
252
  # Append. `at` (ISO8601) goes at the START of the key so `list` returns the notes in
56
253
  # chronological order; `id`/`at` injectable for deterministic tests. -> Note
57
- def add_note(tenant:, text:, id: SecureRandom.uuid, at: nil)
254
+ def add_note(tenant:, text:, id: SecureRandom.uuid, at: nil, customer: nil)
58
255
  at ||= timestamp
59
256
  record = { "id" => id.to_s, "text" => text.to_s, "created_at" => at }
60
- @store.set(scope_for(tenant), NOTE_PREFIX + "#{at}:#{id}", record)
257
+ @store.set(scope_for(tenant, customer), NOTE_PREFIX + "#{at}:#{id}", record)
61
258
  to_note(record)
62
259
  end
63
260
 
261
+ # Cheap fact count for the drill index (keys only, no payload reads). -> Integer
262
+ def fact_count(tenant:, customer: nil)
263
+ @store.list(scope_for(tenant, customer), FACT_PREFIX).size
264
+ end
265
+
64
266
  # -> [Note] MOST RECENT first, capped by `limit`.
65
- def notes(tenant:, limit: nil)
66
- scope = scope_for(tenant)
267
+ def notes(tenant:, limit: nil, customer: nil)
268
+ scope = scope_for(tenant, customer)
67
269
  keys = @store.list(scope, NOTE_PREFIX).reverse # list is chronological -> reverse = most recent first
68
270
  keys = keys.first(limit) if limit
69
271
  keys.filter_map do |k|
@@ -74,12 +276,51 @@ module Insika
74
276
 
75
277
  private
76
278
 
77
- def scope_for(tenant) = "#{SCOPE_PREFIX}:#{tenant.nil? || tenant.to_s.empty? ? DEFAULT_TENANT : tenant}"
279
+ # WS8's rule (executor.rb:1679 parity): customer present -> the
280
+ # [tenant:]customer cell; nil tenant + customer -> the bare customer cell,
281
+ # NEVER memory:_default:<customer>.
282
+ def scope_for(tenant, customer = nil)
283
+ base = Coercion.blank?(tenant) ? DEFAULT_TENANT : tenant
284
+ cust = Coercion.presence(customer)
285
+ return "#{SCOPE_PREFIX}:#{cust}" if cust && Coercion.blank?(tenant)
286
+
287
+ cust ? "#{SCOPE_PREFIX}:#{base}:#{cust}" : "#{SCOPE_PREFIX}:#{base}"
288
+ end
289
+
290
+ def to_fact(record)
291
+ Fact.new(key: record["key"], value: record["value"],
292
+ origin: Coercion.presence(record["origin"]) || ORIGIN_LEGACY,
293
+ created_at: Coercion.presence(record["created_at"]) || Coercion.presence(record["updated_at"]),
294
+ updated_at: record["updated_at"], expires_at: record["expires_at"])
295
+ end
78
296
 
79
- def to_fact(record) = Fact.new(key: record["key"], value: record["value"], updated_at: record["updated_at"])
80
297
  def to_note(record) = Note.new(id: record["id"], text: record["text"], created_at: record["created_at"])
81
298
 
82
- def timestamp = Time.now.utc.iso8601
299
+ def expired?(fact)
300
+ return false if fact.expires_at.nil?
301
+
302
+ Time.iso8601(fact.expires_at) <= now
303
+ rescue ArgumentError
304
+ false
305
+ end
306
+
307
+ def now = (@clock ? @clock.call : Time.now.utc).utc
308
+
309
+ # Microsecond precision ON PURPOSE: updated_at is the CAS revision
310
+ # (replace_if_revision) — second-precision collides for two writes in the
311
+ # same second (WS8).
312
+ def timestamp = now.iso8601(6)
313
+
314
+ # nil | canonical ISO8601(6); invalid -> ValidationError (fail-fast on an
315
+ # LGPD field — a silently dropped expiry date is the defect).
316
+ def normalize_expiry(expires_at)
317
+ return nil if expires_at.nil? || expires_at.to_s.strip.empty?
318
+
319
+ Time.iso8601(expires_at.to_s).utc.iso8601(6)
320
+ rescue ArgumentError
321
+ raise Insika::ValidationError,
322
+ "expires_at must be an ISO8601 timestamp (got #{expires_at.inspect})"
323
+ end
83
324
 
84
325
  def stringify(obj)
85
326
  case obj
@@ -90,4 +331,4 @@ module Insika
90
331
  end
91
332
  end
92
333
  end
93
- end
334
+ end
@@ -33,8 +33,13 @@ module Insika
33
33
  AGENT = "agent" # the model (the default for `assistant`)
34
34
  ENGINE = "engine" # Insika itself, or the consumer composing on its behalf
35
35
  OPERATOR = "operator" # a HUMAN on the assistant side (a handoff; set by importers)
36
+ # the FollowupEngine's synthetic turn — the engine's own kick,
37
+ # never the customer. RESERVED: a consumer cannot declare it (the
38
+ # SendMessage edge refuses the spelling — only the engine creates those
39
+ # turns, D5).
40
+ SCHEDULED = "scheduled"
36
41
 
37
- ALL = [CUSTOMER, AGENT, ENGINE, OPERATOR].freeze
42
+ ALL = [CUSTOMER, AGENT, ENGINE, OPERATOR, SCHEDULED].freeze
38
43
 
39
44
  module_function
40
45
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # — the model-visible payload of ONE ask: the system text,
5
+ # the tool schemas, and the full message stream — exactly what the provider
6
+ # serializes. Captured AT the RubyLLM boundary (the chat object the executor
7
+ # hands to `ask`), never re-derived: re-derivation would re-run volatile
8
+ # context providers (LLM moderation, timestamps) and could drift from the
9
+ # bytes actually sent.
10
+ #
11
+ # "Model-visible means logged": the durable reconstruction of a turn is the
12
+ # checkpoint (messages — the transcript half) + this record (instructions +
13
+ # tool schemas — the half the checkpoint lacks). The conformance suite
14
+ # (spec/insika/conformance/model_visible_spec.rb) asserts the three-way byte
15
+ # identity: capturing chat == checkpoint == trace.
16
+ #
17
+ # Pure reads off the chat object with respond_to? guards — a chat lacking a
18
+ # reader contributes nil/[], never raises (the trace discipline).
19
+ ModelVisible = Data.define(:instructions, :tools, :messages) do
20
+ # -> ModelVisible. `chat` is the RubyLLM chat at the boundary (instructions,
21
+ # tools, messages are exactly the three parts the provider serializes).
22
+ def self.capture(chat)
23
+ new(
24
+ instructions: chat.respond_to?(:instructions) ? chat.instructions : nil,
25
+ tools: chat.respond_to?(:tools) ? Array(chat.tools).map { |t| tool_schema(t) } : [],
26
+ messages: chat.respond_to?(:messages) ? Array(chat.messages) : []
27
+ )
28
+ end
29
+
30
+ # -> Hash: the JSON-safe schema of ONE tool. Reads `parameters` then
31
+ # `schema` (the two reader shapes the house FakeChat and the gem's tools
32
+ # answer); a tool answering none degrades to nil, never raises.
33
+ def self.tool_schema(tool)
34
+ params = if tool.respond_to?(:parameters)
35
+ tool.parameters
36
+ elsif tool.respond_to?(:schema)
37
+ tool.schema
38
+ end
39
+ {
40
+ "name" => (tool.name.to_s if tool.respond_to?(:name)),
41
+ "description" => (tool.description.to_s if tool.respond_to?(:description)),
42
+ "parameters" => params && json_safe(params)
43
+ }.compact
44
+ end
45
+
46
+ # -> JSON-safe projection of an arbitrary schema value. The gem's tools
47
+ # answer parameters as Hash with RubyLLM::Parameter values (a class whose
48
+ # readers are name/type/description/required) — the trace must persist the
49
+ # schema, so it projects that shape instead of raising on the object (the
50
+ # record never breaks the turn). Anything unprojectable degrades to its
51
+ # string form, never raises.
52
+ def self.json_safe(value)
53
+ case value
54
+ when Hash
55
+ value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = json_safe(v) }
56
+ when Array
57
+ value.map { |v| json_safe(v) }
58
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
59
+ value
60
+ else
61
+ if %i[name type description required].all? { |m| value.respond_to?(m) }
62
+ { "name" => value.name.to_s, "type" => value.type.to_s,
63
+ "description" => value.description.to_s, "required" => value.required }
64
+ elsif value.respond_to?(:to_h)
65
+ json_safe(value.to_h)
66
+ else
67
+ value.to_s
68
+ end
69
+ end
70
+ end
71
+
72
+ # The store round-trip: a plain Hash (string keys) -> ModelVisible.
73
+ def self.from_h(hash)
74
+ h = hash || {}
75
+ new(
76
+ instructions: h["instructions"],
77
+ tools: Array(h["tools"]),
78
+ messages: Array(h["messages"])
79
+ )
80
+ end
81
+
82
+ # JSON-safe (string keys), the shape the store persists.
83
+ def to_h
84
+ { "instructions" => instructions, "tools" => tools, "messages" => messages }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # — the durable half of the conformance claim: what the model
5
+ # received, per (task, turn), captured at the RubyLLM boundary and persisted.
6
+ #
7
+ # One record per (task, turn, part) under the scope "model_visible_traces",
8
+ # key "model_visible:<task_id>:turn:<n>:<part>" — `part` separates the
9
+ # turn's own ask ("turn") from engine-internal asks that are model-visible
10
+ # too (the WS4 routing classifier, "routing").
11
+ #
12
+ # A TRACE, best-effort by construction: `record` rescues everything -> nil
13
+ # (a log must never break a turn — the house rule, tool_trace_store.rb).
14
+ # The record is an UPSERT by (task, turn, part): a resumed turn re-records
15
+ # its ask in place (the ContextTraceStore idiom), never duplicates.
16
+ class ModelVisibleTraceStore
17
+ SCOPE = "model_visible_traces"
18
+
19
+ def initialize(store:)
20
+ @store = store
21
+ end
22
+
23
+ # -> ModelVisible | nil (nil = the record failed — the turn proceeds).
24
+ def record(task_id:, turn:, part: "turn", payload:)
25
+ return nil if payload.nil?
26
+
27
+ @store.set(SCOPE, key(task_id, turn, part), payload.to_h)
28
+ payload
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ # -> ModelVisible | nil.
34
+ def find(task_id, turn:, part: "turn")
35
+ raw = @store.get(SCOPE, key(task_id, turn, part))
36
+ raw && ModelVisible.from_h(raw)
37
+ end
38
+
39
+ # -> [ModelVisible] the task's records for `part`, ordered by turn.
40
+ def for_task(task_id, part: "turn")
41
+ @store.list(SCOPE, key_prefix(task_id)).filter_map do |k|
42
+ next unless k.end_with?(":#{part}")
43
+
44
+ n = k[/:turn:(\d+):/, 1]
45
+ n && [Integer(n), ModelVisible.from_h(@store.get(SCOPE, k))]
46
+ end.sort_by(&:first).map(&:last)
47
+ end
48
+
49
+ # Removes every record for a task (retention/LGPD). -> Integer (removed).
50
+ def purge(task_id)
51
+ keys = @store.list(SCOPE, key_prefix(task_id))
52
+ keys.each { |k| @store.delete(SCOPE, k) }
53
+ keys.size
54
+ end
55
+
56
+ private
57
+
58
+ def key(task_id, turn, part)
59
+ "model_visible:#{task_id}:turn:#{Integer(turn)}:#{part}"
60
+ end
61
+
62
+ def key_prefix(task_id)
63
+ "model_visible:#{task_id}:turn:"
64
+ end
65
+ end
66
+ end
@@ -54,8 +54,13 @@ module Insika
54
54
  "observability" => "docs/OBSERVABILITY.md",
55
55
  "evals" => "docs/EVALS.md",
56
56
  "refinement" => "docs/REFINEMENT.md",
57
+ "facts" => "docs/FACTS.md",
58
+ "harvest" => "docs/HARVEST.md",
57
59
  "loadtest" => "docs/LOADTEST.md",
58
- "releasing" => "docs/RELEASING.md"
60
+ "releasing" => "docs/RELEASING.md",
61
+ # the /v1 compatibility contract and the domain-free map.
62
+ "api" => "docs/API.md",
63
+ "domain" => "docs/domain.md"
59
64
  }.freeze
60
65
 
61
66
  # Repo-relative path to the start.md template.
@@ -23,7 +23,7 @@ module Insika
23
23
  # a third party that refused N times is an operator problem, not
24
24
  # something to replay forever.
25
25
  #
26
- # `failed` and `delivering` are a deliberate widening of the RFC's two-status
26
+ # `failed` and `delivering` are a deliberate widening of the two-status
27
27
  # sketch (`pending -> delivered`): without them a crashed claim and an
28
28
  # exhausted retry are indistinguishable from a fresh record, and the sweep
29
29
  # would redeliver both.
@@ -38,7 +38,7 @@ module Insika
38
38
 
39
39
  Delivery = Data.define(
40
40
  :id, :channel, :to, :task_id, :session_id, :payload,
41
- :status, :attempts, :last_error, :created_at, :updated_at
41
+ :status, :attempts, :last_error, :index, :created_at, :updated_at
42
42
  )
43
43
 
44
44
  def initialize(store:)
@@ -47,7 +47,9 @@ module Insika
47
47
 
48
48
  # -> Delivery (:pending). `payload` is the body the channel will send; it is
49
49
  # DATA (string keys, JSON types) and the store never interprets it.
50
- def create(channel:, to:, task_id:, session_id:, payload:, id: SecureRandom.uuid)
50
+ # `index` is the balloon's position inside its turn — 0 for a
51
+ # plain `:at_end` delivery, written by a progressive flush.
52
+ def create(channel:, to:, task_id:, session_id:, payload:, index: 0, id: SecureRandom.uuid)
51
53
  record = {
52
54
  "id" => id.to_s,
53
55
  "channel" => channel.to_s,
@@ -58,6 +60,7 @@ module Insika
58
60
  "status" => "pending",
59
61
  "attempts" => 0,
60
62
  "last_error" => nil,
63
+ "index" => index.to_i,
61
64
  "created_at" => timestamp,
62
65
  "updated_at" => timestamp
63
66
  }
@@ -75,8 +78,12 @@ module Insika
75
78
  # `delivering`: that one was claimed by a process that then died, and whether
76
79
  # its POST landed is unknowable — replaying it is the duplicate the claim
77
80
  # exists to prevent.
81
+ #
82
+ # Ordered by [task_id, index] : a crashed progressive turn re-drives
83
+ # balloon 1 only after balloon 0, never the reverse.
78
84
  def pending
79
85
  scan.select { |d| d.status == :pending }
86
+ .sort_by { |d| [d.task_id.to_s, d.index] }
80
87
  end
81
88
 
82
89
  # pending -> delivering, ATOMICALLY — across processes, not just fibers: the
@@ -127,8 +134,38 @@ module Insika
127
134
  touch(id, record)
128
135
  end
129
136
 
137
+ # WS8 (LGPD): drops every delivery of these sessions, whatever its status.
138
+ # `payload` is the ANSWER as it was handed to the channel, so a purge that
139
+ # stops at the session record leaves the conversation readable here forever.
140
+ # -> count removed.
141
+ def purge_sessions(session_ids)
142
+ wanted = Array(session_ids).map(&:to_s)
143
+ return 0 if wanted.empty?
144
+
145
+ delete_where { |d| wanted.include?(d.session_id.to_s) }
146
+ end
147
+
148
+ # WS8 retention: deliveries created before the cutoff. TERMINAL ones only —
149
+ # a `pending`/`delivering` record older than the window is still somebody's
150
+ # undelivered answer, and the sweep is not the place to decide it is lost.
151
+ # -> count removed.
152
+ def delete_older_than(time)
153
+ cutoff = time.utc.iso8601
154
+ delete_where do |d|
155
+ %i[delivered failed].include?(d.status) && d.created_at.to_s < cutoff
156
+ end
157
+ end
158
+
130
159
  private
131
160
 
161
+ # The id list is SNAPSHOTTED before the deletes: `scan` enumerates the
162
+ # backend's keys lazily and deleting under it would skip records.
163
+ def delete_where(&match)
164
+ doomed = scan.select(&match)
165
+ doomed.each { |d| @store.delete(SCOPE, key_for(d.id)) }
166
+ doomed.size
167
+ end
168
+
132
169
  def scan
133
170
  return enum_for(:scan) unless block_given?
134
171
 
@@ -157,7 +194,8 @@ module Insika
157
194
  task_id: record["task_id"], session_id: record["session_id"],
158
195
  payload: record["payload"], status: record["status"].to_sym,
159
196
  attempts: record["attempts"].to_i, last_error: record["last_error"],
160
- created_at: record["created_at"], updated_at: record["updated_at"]
197
+ index: record["index"].to_i, created_at: record["created_at"],
198
+ updated_at: record["updated_at"]
161
199
  )
162
200
  end
163
201