insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Studio/CLI-facing adapter over Insika::Demo::Seeder — the one path both
8
+ # `insika demo:seed` and the Studio's "Seed demo data" button use (same
9
+ # discipline as RunDistillation: one object, two front doors).
10
+ class SeedDemoData
11
+ def initialize(seeder:, event_stream:)
12
+ @seeder = seeder
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ # payload: { force: bool } -> the seeder's result Hash.
17
+ def call(command)
18
+ force = Coercion.truthy?(command.payload[:force] || command.payload["force"])
19
+ result = @seeder.seed!(force: force)
20
+ if result[:seeded]
21
+ @event_stream.emit(Insika::Event.new(
22
+ type: :demo_data_seeded,
23
+ data: { agent: result[:agent], counts: result[:counts] },
24
+ meta: { at: Time.now.utc.iso8601 }
25
+ ))
26
+ end
27
+ result
28
+ end
29
+ end
30
+ end
31
+ end
@@ -20,12 +20,22 @@ module Insika
20
20
  # nil = no dedup (every surface that does not send an `event_id`, which is all
21
21
  # of them today), and a caller that cannot supply a stable id gets
22
22
  # at-least-once turns rather than a content hash pretending to be dedup.
23
- def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil)
23
+ #
24
+ # `contact_store`/`followup_store` : the contact-state hook —
25
+ # a customer message reopens the contact cell and a cancellation keyword
26
+ # revokes it + falls the pending records. nil = the hook is off (parity).
27
+ # `store` is the SHARED backend the two stores ride — the keyword revoke
28
+ # commits in ONE transaction (D2). nil = best-effort separate writes.
29
+ def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil,
30
+ contact_store: nil, followup_store: nil, store: nil)
24
31
  @profiles = ProfileSource.coerce(profiles)
25
32
  @session_store = session_store
26
33
  @task_store = task_store
27
34
  @executor = executor
28
35
  @inbound_log = inbound_log
36
+ @contact_store = contact_store
37
+ @followup_store = followup_store
38
+ @store = store
29
39
  end
30
40
 
31
41
  def call(command)
@@ -37,8 +47,15 @@ module Insika
37
47
  profile = @profiles[agent] ||
38
48
  (raise Insika::NotFoundError, "agent '#{agent}' not configured")
39
49
 
50
+ # A turn is text OR media. The media half (WS9) is what a voice note
51
+ # with no caption looks like on the wire — `{ parts: [{type: "audio",
52
+ # url: …}] }` and nothing else — and demanding a message here made the
53
+ # anchor use case unreachable end to end: the audio becomes the message
54
+ # at the :media stage, one step later.
40
55
  message = p[:message]
41
- raise Insika::ValidationError, "message is required and non-empty" if message.to_s.strip.empty?
56
+ if message.to_s.strip.empty? && !media?(p[:parts])
57
+ raise Insika::ValidationError, "message is required and non-empty (or a media part)"
58
+ end
42
59
 
43
60
  # session_id XOR history (both -> error; neither -> one-shot).
44
61
  if p[:session_id] && p[:history]
@@ -50,7 +67,14 @@ module Insika
50
67
  # is what a turn has always meant. Refused here rather than downstream: a
51
68
  # typo'd origin would read as absent, and a marker that silently means
52
69
  # "unmarked" is worse than none — it looks like the filtering is on.
53
- Insika::MessageOrigin.parse!(p[:origin])
70
+ origin = Insika::MessageOrigin.parse!(p[:origin])
71
+ # `scheduled` is ENGINE-RESERVED — the FollowupEngine's
72
+ # synthetic turn stamps it, and the edge must not let a consumer
73
+ # impersonate the engine's kick (a spoofed follow-up is the spam bug).
74
+ if origin == Insika::MessageOrigin::SCHEDULED
75
+ raise Insika::ValidationError,
76
+ "origin 'scheduled' is engine-reserved: it is stamped by the follow-up engine only"
77
+ end
54
78
  if p[:session_id]
55
79
  @session_store.find(p[:session_id]) ||
56
80
  (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
@@ -66,6 +90,14 @@ module Insika
66
90
  return { task_id: prior, duplicate: true }
67
91
  end
68
92
 
93
+ # the contact-state hook — the ONLY path that sees every
94
+ # customer message. A real customer message reopens the contact cell; a
95
+ # cancellation keyword revokes it and falls the pending records in ONE
96
+ # transaction. Runs AFTER validation and dedup: a refused or duplicated
97
+ # message must not touch contact state. Nil-safe and policy-gated: no
98
+ # profile declaration = the hook is off (parity).
99
+ touch_contact(p, profile, command, origin)
100
+
69
101
  result = start_turn(command, p, profile)
70
102
  @inbound_log.record(key, result[:task_id]) if key
71
103
  result
@@ -73,6 +105,41 @@ module Insika
73
105
 
74
106
  private
75
107
 
108
+ # the contact bookkeeping of a customer message. The
109
+ # keyword cast IS a revocation: the customer just said the shut-off
110
+ # words — the pending records must fall with the state (D2, ONE
111
+ # transaction). The reset-on-origin list: only an origin that is
112
+ # nil/customer acts — an engine/operator-composed line (even one QUOTING
113
+ # the customer's words) never touches contact state. Nil collaborators
114
+ # or no profile declaration = the hook is a no-op (byte-parity).
115
+ def touch_contact(p, profile, command, origin)
116
+ return unless @contact_store
117
+ return unless origin.nil? || origin == Insika::MessageOrigin::CUSTOMER
118
+
119
+ policy = Insika::FollowupPolicy.parse(profile.followup)
120
+ return unless policy
121
+
122
+ customer = p[:customer]
123
+ return if customer.to_s.empty?
124
+
125
+ keyword = policy.match_keyword(p[:message].to_s)
126
+ if keyword
127
+ # one transaction on the shared backend: the revoke and the pending
128
+ # fall commit together — a half-cancelled opt-out is the spam bug.
129
+ if @store
130
+ @store.transaction do
131
+ @contact_store.set_revoked(tenant: command.meta[:tenant], customer: customer)
132
+ @followup_store&.cancel_pending_for(tenant: command.meta[:tenant], customer: customer)
133
+ end
134
+ else
135
+ @contact_store.set_revoked(tenant: command.meta[:tenant], customer: customer)
136
+ @followup_store&.cancel_pending_for(tenant: command.meta[:tenant], customer: customer)
137
+ end
138
+ else
139
+ @contact_store.set_granted(tenant: command.meta[:tenant], customer: customer)
140
+ end
141
+ end
142
+
76
143
  # The turn (or the verdict that this message joined someone else's).
77
144
  def start_turn(command, p, profile)
78
145
  message = p[:message]
@@ -83,7 +150,10 @@ module Insika
83
150
  # boot). Only offered on a surface that can report the verdict back —
84
151
  # coalescing a caller that cannot hear `merged` makes it deliver the
85
152
  # same answer twice.
86
- if coalescable?(command)
153
+ # A message carrying MEDIA never joins another turn: `collect`/`steer`
154
+ # move TEXT into a task that is already at the door, and its parts would
155
+ # be left behind — the customer's photo would silently not exist.
156
+ if coalescable?(command) && !media?(p[:parts])
87
157
  if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
88
158
  return { task_id: joined, merged: true }
89
159
  end
@@ -101,16 +171,41 @@ module Insika
101
171
  # command.to_h persists the entire Command in the Task;
102
172
  # ResumeTask re-reads payload.message from there.
103
173
  task = @task_store.create(command: command.to_h, session_id: p[:session_id])
174
+ # the channel clock starts HERE — the 202-owning request
175
+ # is accepted, before the SessionActor FIFO and the debounce window.
176
+ # `first_balloon_ms` is the wait the customer feels, so t0 is not the
177
+ # moment the turn finally runs; the same object travels with the turn
178
+ # and `:first_balloon` closes the window at the outbox flush.
104
179
  # `interrupt` mode: the turn in flight is now answering the wrong
105
180
  # question, so it is abandoned at its next boundary. This message keeps its OWN
106
181
  # task and its own reply (that is why it needs no verdict and no surface gate), and
107
182
  # the cancel is posted after `create` so the event can name what replaced what.
108
183
  # No-op in every other mode.
109
184
  @executor.interrupt_running(p[:session_id], profile: profile, replaced_by: task.id)
110
- @executor.spawn_in_session(task, profile: profile)
185
+ @executor.spawn_in_session(task, profile: profile,
186
+ timing: channel_inbound_timing(command))
111
187
  { task_id: task.id }
112
188
  end
113
189
 
190
+ # allocate the channel clock at 202 acceptance and stamp
191
+ # `:inbound` — the window's start. `breakdown: false` when INSIKA_TURN_TIMING
192
+ # is off, so a channel turn measures ONLY first_balloon_ms (H-latência never
193
+ # depends on the flag). nil for every non-channel transport: no clock to start.
194
+ def channel_inbound_timing(command)
195
+ return nil unless command.meta[:transport].to_s.start_with?("channel:")
196
+
197
+ timing = Insika::TurnTiming.new(breakdown: Insika::TurnTiming.enabled?)
198
+ timing.mark(:inbound)
199
+ timing
200
+ end
201
+
202
+ # Does the payload carry a part the engine will turn into the turn's
203
+ # substance — audio (transcribed into the message) or an image (attached
204
+ # to the ask)? A text part is not media: it is the message, spelled long.
205
+ def media?(parts)
206
+ Insika::Media.parts(parts).any? { |p| p.audio? || p.image? }
207
+ end
208
+
114
209
  def coalescable?(command)
115
210
  transport = command.meta[:transport]
116
211
  COALESCABLE_TRANSPORTS.include?(transport) || transport.to_s.start_with?("channel:")
@@ -133,7 +228,9 @@ module Insika
133
228
  session_id: payload[:session_id] || payload["session_id"],
134
229
  history: payload[:history] || payload["history"],
135
230
  origin: payload[:origin] || payload["origin"],
136
- event_id: payload[:event_id] || payload["event_id"]
231
+ event_id: payload[:event_id] || payload["event_id"],
232
+ parts: payload[:parts] || payload["parts"],
233
+ customer: payload[:customer] || payload["customer"]
137
234
  }
138
235
  end
139
236
 
@@ -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
@@ -8,17 +8,20 @@ module Insika
8
8
  # (transport/command/url/enabled + `env` credentials) in the McpStore. The
9
9
  # credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
10
10
  # a new string replaces). Returns the MASKED record (env never comes back in
11
- # plaintext). Durable config CRUD — running an MCP client against the
12
- # instance is later runtime.
11
+ # plaintext). Evicts any memoized client for this name (mcp_registry is
12
+ # optional so a bare CLI/store-only caller need not wire one) so an edited
13
+ # command/url/env takes effect on the instance's next call — no restart.
13
14
  class UpsertMcp
14
- def initialize(mcp_store:, event_stream:)
15
+ def initialize(mcp_store:, event_stream:, mcp_registry: nil)
15
16
  @mcp_store = mcp_store
16
17
  @event_stream = event_stream
18
+ @mcp_registry = mcp_registry
17
19
  end
18
20
 
19
21
  def call(command)
20
22
  p = AgentPayload.symbolize(command.payload)
21
23
  masked = @mcp_store.upsert(p) # validates `name`; returns masked
24
+ @mcp_registry&.evict(masked["name"])
22
25
 
23
26
  @event_stream.emit(Insika::Event.new(
24
27
  type: :mcp_upserted,
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes a concept (complete concept markdown) into the
8
+ # KnowledgeStore. Validates the frontmatter (a `name` is required) before
9
+ # writing — same discipline as `WriteSkill`. The STORE POSITION (the
10
+ # `agent`/`name`/`tenant` triple) is the identity, exactly like a skill:
11
+ # an operator editing the raw markdown may leave the frontmatter `name:`
12
+ # unchanged even while promoting `provenance: observed` to `policy` —
13
+ # that promotion IS this command, no separate "promote" action needed.
14
+ # -> { name, agent, tenant, updated_at }.
15
+ class WriteConcept
16
+ def initialize(knowledge_store:, event_stream:)
17
+ @knowledge_store = knowledge_store
18
+ @event_stream = event_stream
19
+ end
20
+
21
+ def call(command)
22
+ p = AgentPayload.symbolize(command.payload)
23
+ name = AgentPayload.presence(p[:name])
24
+ agent = AgentPayload.presence(p[:agent])
25
+ tenant = AgentPayload.presence(p[:tenant])
26
+ content = p[:content].to_s
27
+ raise Insika::ValidationError, "name is required" if name.nil?
28
+ raise Insika::ValidationError, "agent is required" if agent.nil?
29
+
30
+ parsed = validate_frontmatter!(content)
31
+ rec = @knowledge_store.write(agent, name, content, tenant: tenant)
32
+ emit(parsed[:type], name, agent)
33
+ { name: name, agent: agent, tenant: tenant, updated_at: rec["updated_at"] }
34
+ end
35
+
36
+ private
37
+
38
+ def emit(type, name, agent)
39
+ @event_stream.emit(Insika::Event.new(
40
+ type: :knowledge_learned, data: { name: name, type: type, agent: agent },
41
+ meta: { at: Time.now.utc.iso8601 }
42
+ ))
43
+ end
44
+
45
+ # Mirrors WriteSkill's frontmatter guard: without a parseable `name`,
46
+ # the concept would be unreachable by name once written (the Studio
47
+ # list keys on the store position, but the editor round-trips the
48
+ # content) — fail early, in the Command.
49
+ def validate_frontmatter!(content)
50
+ parsed = Insika::Knowledge::Concept.parse(content)
51
+ raise Insika::ValidationError, "concept missing YAML frontmatter (--- ... ---) or a `name`" if parsed.nil?
52
+
53
+ parsed
54
+ end
55
+ end
56
+ end
57
+ end
@@ -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
@@ -20,8 +20,13 @@ module Insika
20
20
  PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
21
21
  SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
22
22
  SKILL = 80 # <available_skills> level 1 (Skill)
23
+ KNOWLEDGE = 77 # <knowledge> top-K learned concepts (Knowledge) —
24
+ # below curated skills, above a single conversation's memory
23
25
  MEMORY = 75 # <memory> read path (Memory)
24
26
  TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
27
+ BRIEFING = 65 # <briefing> session working state (Briefing) — D5:
28
+ # below every identity/skill/memory block (never breaks the
29
+ # pinned prefix), above the turn's own <request_context>.
25
30
  HISTORY_MAX = 79 # history ceiling by recency (Session)
26
31
  HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
27
32
  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