insika 0.1.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # The CONVERSATION footprint of a session, purged (WS8/LGPD). Shared by
6
+ # `forget_customer` and `delete_tenant_data` because "erase this person"
7
+ # and "erase this tenant" differ only in WHICH sessions they name — what a
8
+ # session leaves behind is the same list, and a second copy of that list is
9
+ # a second thing to forget to update.
10
+ #
11
+ # Deleting the session record alone is NOT erasure: the customer's own text
12
+ # lives in the task's persisted command payload, the whole transcript lives
13
+ # in the turn's checkpoints, and the answer as it was handed to the channel
14
+ # lives in the outbox record's payload. All four go together or none of them
15
+ # counts.
16
+ #
17
+ # A task is deleted whatever its status — this is a deletion order, not the
18
+ # retention sweep (which spares live tasks on purpose). The stores it needs
19
+ # beyond the session are optional (deployment components): a graph without
20
+ # them purges what it has and reports zero for the rest.
21
+ module SessionPurge
22
+ # ids: the session ids to erase. -> { tasks:, checkpoints:, deliveries:, pairs: }
23
+ def purge_sessions(ids)
24
+ ids = Array(ids).map(&:to_s)
25
+ return { tasks: 0, checkpoints: 0, deliveries: 0, pairs: 0 } if ids.empty?
26
+
27
+ tasks, checkpoints, model_visible = purge_tasks_of(ids)
28
+ deliveries = @outbox_store ? @outbox_store.purge_sessions(ids) : 0
29
+ pairs = @shadow_pairs ? @shadow_pairs.purge_sessions(ids) : 0
30
+ ids.each do |id|
31
+ @tool_trace_store&.clear(id)
32
+ @context_trace_store&.clear(id)
33
+ @session_store.delete(id)
34
+ end
35
+ { tasks: tasks, checkpoints: checkpoints, model_visible: model_visible,
36
+ deliveries: deliveries, pairs: pairs }
37
+ end
38
+
39
+ private
40
+
41
+ # -> [tasks removed, checkpoint records removed, model-visible records removed].
42
+ # the model-visible traces are transcripts — they die next to
43
+ # their checkpoints (the same single list, so "erase this person" cannot
44
+ # forget one half). The id list is SNAPSHOTTED (`to_a`) before the deletes:
45
+ # `each_id` enumerates the backend's keys lazily, and deleting under it
46
+ # would skip records.
47
+ def purge_tasks_of(ids)
48
+ return [0, 0, 0] unless @task_store
49
+
50
+ wanted = ids.each_with_object({}) { |id, acc| acc[id] = true }
51
+ tasks = 0
52
+ checkpoints = 0
53
+ model_visible = 0
54
+ @task_store.each_id.to_a.each do |task_id|
55
+ task = @task_store.find(task_id)
56
+ next unless task && wanted[task.session_id.to_s]
57
+
58
+ checkpoints += @checkpoint_store ? @checkpoint_store.purge(task_id) : 0
59
+ model_visible += @model_visible_trace_store ? @model_visible_trace_store.purge(task_id) : 0
60
+ @task_store.delete(task_id)
61
+ tasks += 1
62
+ end
63
+ [tasks, checkpoints, model_visible]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -6,7 +6,7 @@ module Insika
6
6
  module Commands
7
7
  # Control command: adjusts an agent's tools allow/denylist.
8
8
  # `allow` nil = all (AgentProfile rule); `deny` always
9
- # wins. `allow_groups` (Phase 7/D4/F5, Step C): per-group allowlist, only
9
+ # wins. `allow_groups`: per-group allowlist, only
10
10
  # overwritten if the key comes in the payload (otherwise preserved). Takes effect on the next
11
11
  # dispatch (hot). -> AgentProfile.
12
12
  class SetAgentTools
@@ -5,8 +5,15 @@ require "time"
5
5
  module Insika
6
6
  module Commands
7
7
  # Control command: enables/disables a skill on N
8
- # agents at once, adjusting each one's `profile.skills` allowlist.
9
- # Takes effect on the next dispatch (hot via ProfileSource). -> { name, enabled_for }.
8
+ # agents at once, adjusting each one's `profile.skills` allowlist — and, with
9
+ # `eager_ids`, which of them keep the body in the prompt on EVERY turn
10
+ # (`profile.skills_eager`). Takes effect on the next dispatch (hot via
11
+ # ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
12
+ #
13
+ # Both are per-agent decisions about the same skill, which is why they are one
14
+ # command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
15
+ # the screen that says "which agents can load this" is the screen that says "and
16
+ # which of them always have it".
10
17
  #
11
18
  # Allowlist semantics (AgentProfile): nil = ALL, [] = none,
12
19
  # [names] = subset. Important and deliberate consequence:
@@ -16,6 +23,11 @@ module Insika
16
23
  # allowlist (destructive/surprising). These agents are left intact and
17
24
  # go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
18
25
  # first.
26
+ #
27
+ # `skills_eager` is NOT allowlist semantics (nil = NONE, see
28
+ # SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
29
+ # there is an empty set that can simply be added to, and only the BLANKET `true`
30
+ # has the same "cannot remove one name from all" problem (-> skipped_eager_all).
19
31
  class SetSkillAgents
20
32
  def initialize(profile_source:, event_stream:)
21
33
  @profile_source = profile_source
@@ -27,35 +39,46 @@ module Insika
27
39
  name = AgentPayload.presence(p[:name])
28
40
  raise Insika::ValidationError, "name is required" if name.nil?
29
41
  raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
42
+ raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
30
43
 
31
44
  wanted = Array(p[:agent_ids]).map(&:to_s)
32
- enabled_for = []
33
- skipped_all = []
34
-
35
- @profile_source.all.each do |profile|
36
- want = wanted.include?(profile.id)
37
- new_skills = next_skills(profile.skills, name, want)
38
-
39
- if new_skills == :skip
40
- skipped_all << profile.id
41
- next
42
- end
43
- next if new_skills == profile.skills # no change
45
+ # eager_ids ABSENT means "this form does not manage eagerness" — leave every
46
+ # profile's setting alone. An empty ARRAY means "none of them", which is a real
47
+ # instruction and must be applied. `nil` and `[]` are not the same request.
48
+ eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
49
+ result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
44
50
 
45
- @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(skills: new_skills)))
46
- enabled_for << profile.id if want
47
- end
51
+ @profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
48
52
 
49
53
  @event_stream.emit(Insika::Event.new(
50
54
  type: :skill_agents_set,
51
- data: { name: name, agent_ids: wanted, skipped_all: skipped_all },
55
+ data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
56
+ skipped_all: result[:skipped_all] }.compact,
52
57
  meta: { at: Time.now.utc.iso8601 }
53
58
  ))
54
- { name: name, enabled_for: enabled_for, skipped_all: skipped_all }
59
+ { name: name }.merge(result)
55
60
  end
56
61
 
57
62
  private
58
63
 
64
+ def apply(profile, name, wanted, eager_wanted, result)
65
+ want = wanted.include?(profile.id)
66
+ new_skills = next_skills(profile.skills, name, want)
67
+ new_eager = next_eager(profile, name, eager_wanted, result)
68
+
69
+ if new_skills == :skip
70
+ result[:skipped_all] << profile.id
71
+ new_skills = profile.skills
72
+ end
73
+ result[:enabled_for] << profile.id if want
74
+ result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
75
+ return if new_skills == profile.skills && new_eager == profile.skills_eager
76
+
77
+ @profile_source.put(Insika::AgentProfile.build(
78
+ **profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
79
+ ))
80
+ end
81
+
59
82
  # -> new allowlist | :skip (agent with nil that requested disable).
60
83
  def next_skills(current, name, want)
61
84
  if want
@@ -66,6 +89,24 @@ module Insika
66
89
  Array(current).map(&:to_s) - [name]
67
90
  end
68
91
  end
92
+
93
+ # -> new skills_eager. nil eager_wanted = not managed by this call.
94
+ def next_eager(profile, name, eager_wanted, result)
95
+ current = profile.skills_eager
96
+ return current if eager_wanted.nil?
97
+
98
+ want = eager_wanted.include?(profile.id)
99
+ # Blanket `true` already includes every skill; removing ONE name from it would
100
+ # mean materializing the whole catalog as a list, the same destructive surprise
101
+ # `skipped_all` exists for.
102
+ if Coercion.truthy?(current)
103
+ result[:skipped_eager_all] << profile.id unless want
104
+ return current
105
+ end
106
+
107
+ list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
108
+ want ? (list | [name]) : (list - [name])
109
+ end
69
110
  end
70
111
  end
71
112
  end
@@ -45,7 +45,7 @@ module Insika
45
45
  raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
46
46
  end
47
47
 
48
- # I/O by schema (item 22 / §4.4): the INPUT is validated SYNCHRONOUSLY —
48
+ # I/O by schema: the INPUT is validated SYNCHRONOUSLY —
49
49
  # a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
50
50
  # with NO run created. `definition` reads the schema metadata without
51
51
  # resolving the factory (stays out of the fiber). No-op without an input_schema.
@@ -25,7 +25,7 @@ module Insika
25
25
  # what was sent. id never changes (rename = create+delete, out of scope).
26
26
  merged = existing.to_h.merge(patch).merge(id: id)
27
27
  profile = Insika::AgentProfile.build(**merged)
28
- # RFC-0010 §4.4: definition-time cycle + depth check (an update may ADD
28
+ # definition-time cycle + depth check (an update may ADD
29
29
  # subagents to an existing agent). Raises SubagentError before persisting.
30
30
  validate_subagent_graph!(profile)
31
31
  @profile_source.put(profile)
@@ -5,7 +5,7 @@ require "time"
5
5
  module Insika
6
6
  module Commands
7
7
  # Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
8
- # catalog — takes effect without a restart (F5). Rejects a name that collides with a
8
+ # catalog — takes effect without a restart. Rejects a name that collides with a
9
9
  # code tool (R3). Validating the definition (format, method/url, types,
10
10
  # placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
11
11
  # in the store; the event carries only the name (0 leakage). -> { name, definition }.
@@ -4,7 +4,7 @@ require "time"
4
4
 
5
5
  module Insika
6
6
  module Commands
7
- # Control command: writes an eval case (RFC-0008 §3.1) into the GoldenStore. This
7
+ # Control command: writes an eval case into the GoldenStore. This
8
8
  # is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
9
9
  # that is plain language — without a checkout and a text editor.
10
10
  #
@@ -5,10 +5,15 @@ require "yaml"
5
5
 
6
6
  module Insika
7
7
  module Commands
8
- # Control command: writes a shared skill
9
- # (complete SKILL.md) into the SkillStore and RELOADS the catalog — takes effect
10
- # without a restart (hot). Validates the frontmatter (name required) before writing.
11
- # -> { name, updated_at }.
8
+ # Control command: writes a skill (complete SKILL.md) into
9
+ # the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
10
+ # Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
11
+ #
12
+ # `agent` (optional) writes into that agent's scope instead of the shared one: the
13
+ # SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
14
+ # either way — the store position is the identity, so the frontmatter inside an
15
+ # override keeps saying the bare shared name and the level-1 catalog, load_skill,
16
+ # the labels and the events all keep displaying it.
12
17
  class WriteSkill
13
18
  def initialize(skill_store:, skill_catalog:, event_stream:)
14
19
  @skill_store = skill_store
@@ -19,21 +24,26 @@ module Insika
19
24
  def call(command)
20
25
  p = AgentPayload.symbolize(command.payload)
21
26
  name = AgentPayload.presence(p[:name])
27
+ agent = AgentPayload.presence(p[:agent])
22
28
  content = p[:content].to_s
23
29
  raise Insika::ValidationError, "name is required" if name.nil?
24
30
  validate_frontmatter!(content)
25
31
 
26
- rec = @skill_store.write(name, content, create_only: !!p[:create_only])
32
+ rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
27
33
  @skill_catalog.reload
34
+ emit(:skill_written, name, agent)
35
+ { name: name, agent: agent, updated_at: rec["updated_at"] }
36
+ end
37
+
38
+ private
39
+
40
+ def emit(type, name, agent)
28
41
  @event_stream.emit(Insika::Event.new(
29
- type: :skill_written, data: { name: name },
42
+ type: type, data: { name: name, agent: agent }.compact,
30
43
  meta: { at: Time.now.utc.iso8601 }
31
44
  ))
32
- { name: name, updated_at: rec["updated_at"] }
33
45
  end
34
46
 
35
- private
36
-
37
47
  # Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
38
48
  # would be silently ignored on reload — fail early, in the Command.
39
49
  def validate_frontmatter!(content)
@@ -18,15 +18,19 @@ module Insika
18
18
  # agent_files/skills: the CONTENT of per-agent prompts
19
19
  # and shared skills lives in the Store (single source of truth,
20
20
  # a SQLite backup), not on disk — disk becomes only seed/import.
21
- # goldens: authored eval cases (RFC-0008 §3.1 / RFC-0013 §3.7) — config, like the
21
+ # goldens: authored eval cases — config, like the
22
22
  # prompts and skills: the corpus on disk is seed and export, the store is what a
23
23
  # deployment runs and what the Studio edits.
24
- # baselines: the ACCEPTED state of each agent's golden set (RFC-0013 §3.7.3).
24
+ # baselines: the ACCEPTED state of each agent's golden set.
25
25
  # Config for the same reason: it is a curated decision ("this is the bar"), the
26
26
  # file is its export, and the refinement gate reads it from inside a deployment
27
27
  # that has no checkout.
28
- SCOPES = %w[agents settings llm_providers mcp agent_files skills system_files tools
29
- goldens baselines].freeze
28
+ # agent_skills: the per-agent SPECIALIZATIONS of a shared skill (and
29
+ # agent-private skills). A second scope rather than a composite key in `skills`,
30
+ # so the shared records are untouched by the agent dimension arriving — no
31
+ # migration, and a live deployment keeps serving exactly what it served.
32
+ SCOPES = %w[agents settings llm_providers mcp agent_files skills agent_skills
33
+ system_files tools goldens baselines].freeze
30
34
 
31
35
  class UnknownScope < Insika::Error; end
32
36
 
@@ -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
@@ -19,7 +19,7 @@ module Insika
19
19
  def initialize(providers:, event_stream:, hooks: Hooks.new, estimator: TokenEstimator)
20
20
  @providers = providers
21
21
  @event_stream = event_stream
22
- @hooks = hooks # :prompt pair (task 16); empty Hooks = no-op
22
+ @hooks = hooks # prompt pair; empty Hooks = no-op
23
23
  @estimator = estimator
24
24
  end
25
25
 
@@ -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
@@ -97,7 +104,7 @@ module Insika
97
104
  def estimable_text(content)
98
105
  case content
99
106
  when String then content
100
- # An eviction unit (§11 R1): a cycle of message Hashes -> sum their text.
107
+ # An eviction unit (R1): a cycle of message Hashes -> sum their text.
101
108
  when Array then content.map { |c| estimable_text(c) }.join(" ")
102
109
  when Hash then content.values.map(&:to_s).join(" ")
103
110
  else content.to_s
@@ -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,12 +8,40 @@ 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.
15
+ # labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
16
+ # as ids. Content-FREE by contract, so the context trace can report
17
+ # which skills a turn injected without storing a byte of the bodies.
18
+ # [] = nothing to name (the default for every provider that has no
19
+ # natural id, e.g. the identity prompt).
20
+ #
21
+ # The REASON is the point. A name alone answers "was something
22
+ # injected"; the operator's actual question is "which skill did I
23
+ # trigger, and why is it here" — `eager` (the agent always wants it),
24
+ # `trigger:<matched phrase>` (this message asked for it), or absent
25
+ # for a body a plugin supplied. String keys because these labels are
26
+ # written to the context trace and to events as JSON: the round-trip
27
+ # is then the identity, and no reader has to defend against both.
11
28
  ContextFragment = Data.define(:content, :placement, :priority, :tokens,
12
- :source, :pinned) do
29
+ :source, :pinned, :labels, :layer) do
13
30
  def self.build(content:, placement:, source:, priority: 50, tokens: nil,
14
- pinned: false)
31
+ pinned: false, labels: [], layer: nil)
15
32
  new(content: content, placement: placement, priority: priority,
16
- tokens: tokens, source: source, pinned: pinned)
33
+ tokens: tokens, source: source, pinned: pinned,
34
+ labels: Array(labels).map { |l| label(l) }, layer: layer)
35
+ end
36
+
37
+ # A bare String is still a valid label (a provider that has an id but no reason
38
+ # to give) — it normalizes to a reason-less entry rather than being rejected.
39
+ def self.label(raw)
40
+ return { "name" => raw.to_s }.freeze unless raw.is_a?(Hash)
41
+
42
+ name = (raw[:name] || raw["name"]).to_s
43
+ reason = raw[:reason] || raw["reason"]
44
+ { "name" => name, "reason" => reason&.to_s }.compact.freeze
17
45
  end
18
46
  end
19
47
  end
@@ -3,12 +3,12 @@
3
3
  module Insika
4
4
  module Context
5
5
  # Precedence ladder for context fragments — SINGLE SOURCE of the order
6
- # (Phase 6/D5, trust boundary). Higher = more authority: appears earlier
6
+ # (trust boundary). Higher = more authority: appears earlier
7
7
  # in the system prompt and survives budget cuts. Providers reference these
8
8
  # constants instead of loose numbers, so the boundary is an auditable
9
9
  # contract in a single place (trust_boundary_spec locks the order).
10
10
  #
11
- # Contract (NF3/D5): identity and guardrails go PINNED at the top; TURN
11
+ # Contract: identity and guardrails go PINNED at the top; TURN
12
12
  # injections (request_context — the consumer's tenant/vars) sit at the
13
13
  # BOTTOM and are sacrificed FIRST under budget. Identity (pinned) is NEVER
14
14
  # truncated. A prompt injection riding in via turn data is DATA, not
@@ -18,9 +18,13 @@ module Insika
18
18
  module Priority
19
19
  IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
20
20
  PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
21
+ SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
21
22
  SKILL = 80 # <available_skills> level 1 (Skill)
22
23
  MEMORY = 75 # <memory> read path (Memory)
23
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>.
24
28
  HISTORY_MAX = 79 # history ceiling by recency (Session)
25
29
  HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
26
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