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
@@ -7,10 +7,15 @@ module Insika
7
7
  # Control command: forgets (removes) a fact from the
8
8
  # agent's memory. Idempotent: forgetting something that doesn't exist is not an error
9
9
  # (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
10
+ #
11
+ # with an `audit_store:` collaborator, appends a content-free
12
+ # audit line (old_hash only — the deleted value's digest, never the
13
+ # value). Optional: nil = no audit.
10
14
  class MemoryForgetFact
11
- def initialize(memory_store:, event_stream:)
15
+ def initialize(memory_store:, event_stream:, audit_store: nil)
12
16
  @memory_store = memory_store
13
17
  @event_stream = event_stream
18
+ @audit_store = audit_store
14
19
  end
15
20
 
16
21
  def call(command)
@@ -19,14 +24,25 @@ module Insika
19
24
  raise Insika::ValidationError, "key is required" if key.nil?
20
25
 
21
26
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
22
- existed = @memory_store.forget_fact(tenant: tenant, key: key)
27
+ customer = AgentPayload.presence(p[:customer])
28
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
29
+
30
+ scope = @memory_store.cell_for(tenant, customer)
31
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
32
+ existed = @memory_store.forget_fact(tenant: tenant, key: key, customer: customer)
33
+ @audit_store&.record(
34
+ cell: scope, action: "forget", actor: operator, key: key,
35
+ tenant: tenant, customer: customer,
36
+ old_hash: old && MemoryAuditStore.digest(old.value)
37
+ )
23
38
  @event_stream.emit(Insika::Event.new(
24
39
  type: :memory_fact_forgotten,
25
- data: { tenant: tenant, key: key, existed: existed },
40
+ data: { tenant: tenant, key: key, customer: customer,
41
+ existed: existed }.compact,
26
42
  meta: { at: Time.now.utc.iso8601 }
27
43
  ))
28
44
  { existed: existed }
29
45
  end
30
46
  end
31
47
  end
32
- end
48
+ end
@@ -9,10 +9,16 @@ module Insika
9
9
  # from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
10
10
  # Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
11
11
  # Synchronous; does not create a Task. -> Fact.
12
+ #
13
+ # the OPERATOR surface — every write stamps `origin: "operator"`
14
+ # (the `remember` tool keeps "engine") and, with an `audit_store:`
15
+ # collaborator, appends a content-free audit line (old/new digests). The
16
+ # audit is optional: nil = no audit (specs, minimal graphs).
12
17
  class MemoryPutFact
13
- def initialize(memory_store:, event_stream:)
18
+ def initialize(memory_store:, event_stream:, audit_store: nil)
14
19
  @memory_store = memory_store
15
20
  @event_stream = event_stream
21
+ @audit_store = audit_store
16
22
  end
17
23
 
18
24
  def call(command)
@@ -22,14 +28,27 @@ module Insika
22
28
  raise Insika::ValidationError, "value is required" if p[:value].nil?
23
29
 
24
30
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
25
- fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value])
31
+ customer = AgentPayload.presence(p[:customer])
32
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
33
+
34
+ scope = @memory_store.cell_for(tenant, customer)
35
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
36
+ fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value],
37
+ customer: customer, origin: "operator",
38
+ expires_at: AgentPayload.presence(p[:expires_at]))
39
+ @audit_store&.record(
40
+ cell: scope, action: "put", actor: operator, key: key,
41
+ tenant: tenant, customer: customer,
42
+ old_hash: old && MemoryAuditStore.digest(old.value),
43
+ new_hash: MemoryAuditStore.digest(fact.value)
44
+ )
26
45
  @event_stream.emit(Insika::Event.new(
27
46
  type: :memory_fact_put,
28
- data: { tenant: tenant, key: key },
47
+ data: { tenant: tenant, key: key, customer: customer }.compact,
29
48
  meta: { at: Time.now.utc.iso8601 }
30
49
  ))
31
50
  fact
32
51
  end
33
52
  end
34
53
  end
35
- end
54
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the human's answer, the ONLY path that lands a
8
+ # skill. Snapshot FIRST (the pre-promotion state), then the two existing
9
+ # write commands, then the append-only log row (D8). A promotion row's
10
+ # `rolled_back_at` makes the log the single ledger; re-promotion after a
11
+ # rollback is a NEW row with a NEW snapshot. The candidate flips to
12
+ # `promoted` ONLY after the writes land (record-after).
13
+ class PromoteHarvest
14
+ def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
15
+ criterion:, conversion_gate:, event_stream:,
16
+ write_skill: nil, set_skill_agents: nil)
17
+ @harvest_store = harvest_store
18
+ @skill_store = skill_store
19
+ @skill_catalog = skill_catalog
20
+ @profiles = ProfileSource.coerce(profile_source)
21
+ @criterion = criterion
22
+ @conversion_gate = conversion_gate
23
+ @event_stream = event_stream
24
+ @write_skill = write_skill ||
25
+ Insika::Commands::WriteSkill.new(skill_store: skill_store,
26
+ skill_catalog: skill_catalog,
27
+ event_stream: event_stream)
28
+ @set_skill_agents = set_skill_agents ||
29
+ Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
30
+ event_stream: event_stream)
31
+ end
32
+
33
+ # payload: { candidate_id:, operator?, note? }
34
+ # operator from payload || command.meta[:operator] || "operator"
35
+ # -> Candidate (promoted | gated — the conversion re-check moved, D8-bis)
36
+ def call(command)
37
+ p = AgentPayload.symbolize(command.payload)
38
+ candidate_id = AgentPayload.presence(p[:candidate_id])
39
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
40
+
41
+ candidate = @harvest_store.find_candidate(candidate_id) ||
42
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
43
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
44
+
45
+ # D8-bis — the conversion ruler is RE-READ at the moment the skill
46
+ # lands (cheap: a fold read). A dip below the frozen baseline since
47
+ # gating PARKS the candidate at gated with the current numbers — the
48
+ # operator re-decides alertly. The eval gate is NOT re-run (expensive,
49
+ # and the skill body is not anchored to instruction files — nothing
50
+ # drifted to re-validate). A base graph without a conversion gate
51
+ # refuses with the named reason — never passes, never crashes.
52
+ current = if @conversion_gate
53
+ @conversion_gate.call(tenant: command.meta[:tenant], agent: candidate.agent)
54
+ else
55
+ Insika::Harvest::ConversionGate::Result.new(
56
+ passed: false, reason: :no_conversion_gate_wired, metric: nil, window: nil,
57
+ current: nil, baseline: nil, threshold: nil, snapshot_ref: nil
58
+ )
59
+ end
60
+ return @harvest_store.recheck_conversion(candidate.id, conversion_gate: current.to_h) unless current.passed
61
+
62
+ # The criterion must STILL be the frozen one (D8-bis): a criterion that
63
+ # changed between boot and promotion is a criterion nobody froze.
64
+ unless @criterion && @criterion.sha == candidate.criterion_sha
65
+ raise Insika::ValidationError,
66
+ "the harvest criterion changed since boot (#{candidate.criterion_sha.inspect} -> " \
67
+ "#{@criterion&.sha.inspect}) — re-gate the candidate against the new frozen file"
68
+ end
69
+
70
+ # Snapshot first, then the writes, then the log row, then the flip.
71
+ existed = @skill_store.get(candidate.name, agent: candidate.agent)
72
+ # D8 + the review fix: the snapshot records EVERY agent the skill was
73
+ # enabled for BEFORE this promotion (SetSkillAgents' vocabulary). A
74
+ # homonymous skill allowed by B must stay allowed when A promotes —
75
+ # and the rollback restores exactly this set, B included.
76
+ enabled_for = agents_with_skill(candidate.name)
77
+ snapshot = @harvest_store.create_snapshot(
78
+ agent: candidate.agent, skill: candidate.name, content: existed,
79
+ existed: !existed.nil?,
80
+ enabled_for: enabled_for
81
+ )
82
+
83
+ @write_skill.call(Insika::Command.build(:write_skill,
84
+ { name: candidate.name, content: candidate.body,
85
+ agent: candidate.agent }))
86
+ # ENABLE for the candidate's agent WITHOUT disabling anyone else who
87
+ # already had it: the wanted set is the pre-promotion holders plus the
88
+ # candidate.
89
+ @set_skill_agents.call(Insika::Command.build(:set_skill_agents,
90
+ { name: candidate.name,
91
+ agent_ids: enabled_for | [candidate.agent] }))
92
+
93
+ promotion = @harvest_store.append_promotion(
94
+ id: SecureRandom.uuid, agent: candidate.agent, skill: candidate.name,
95
+ origin: candidate.origin, eval_ref: "cand:#{candidate.id}",
96
+ conversion_ref: candidate.conversion_gate && candidate.conversion_gate["snapshot_ref"],
97
+ approver: operator, snapshot_ref: snapshot.id,
98
+ criterion_sha: candidate.criterion_sha
99
+ )
100
+ @harvest_store.mark_promoted(candidate.id, promotion_ref: promotion.id)
101
+
102
+ emit(:skill_promoted, agent: candidate.agent, skill: candidate.name,
103
+ candidate_id: candidate.id, snapshot_ref: snapshot.id,
104
+ promotion_ref: promotion.id, approver: operator)
105
+ @harvest_store.find_candidate(candidate.id)
106
+ end
107
+
108
+ private
109
+
110
+ def profile_of(agent_id)
111
+ @profiles.fetch(agent_id) ||
112
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
113
+ end
114
+
115
+ # Every agent whose allowlist currently names the skill — the pre-
116
+ # promotion holder set (an agent with skills=nil has ALL, so it holds
117
+ # every name and is not "removable"; SetSkillAgents skips it).
118
+ def agents_with_skill(name)
119
+ @profiles.all.select { |p| Array(p.skills).include?(name) }.map(&:id)
120
+ end
121
+
122
+ def emit(type, **data)
123
+ # ids and refs only — the skill body never enters the stream (D7).
124
+ @event_stream.emit(Insika::Event.new(
125
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
126
+ ))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS7): the operator or the integration records a business
8
+ # OUTCOME for a conversation — `{ outcome: "conversion", value: R$ }` — via
9
+ # `POST /v1/outcomes`. The engine transports it and never interprets it; the
10
+ # Studio's scorecard card aggregates the store. The record is tenant-stamped
11
+ # (WS1): a tenant principal's outcomes land under its own tenant, invisible
12
+ # to every other tenant and to nothing the tenant can read.
13
+ class RecordOutcome
14
+ def initialize(outcome_store:, event_stream:)
15
+ @outcome_store = outcome_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ payload = command.payload
21
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
22
+ outcome = Coercion.presence(payload[:outcome] || payload["outcome"])
23
+ raise ValidationError, "agent is required" if agent.nil?
24
+ raise ValidationError, "outcome is required" if outcome.nil?
25
+
26
+ value = payload[:value] || payload["value"]
27
+ unless value.nil? || value.is_a?(Numeric)
28
+ raise ValidationError, "value must be a number"
29
+ end
30
+
31
+ session_id = payload[:session_id] || payload["session_id"]
32
+ record = @outcome_store.create(
33
+ tenant: command.meta[:tenant], agent: agent, session_id: session_id,
34
+ outcome: outcome, value: value
35
+ )
36
+ @event_stream.emit(Insika::Event.new(
37
+ type: :outcome_recorded,
38
+ data: { agent: agent, outcome: outcome, value: record.value,
39
+ session_id: record.session_id },
40
+ meta: { at: Time.now.utc.iso8601 }
41
+ ))
42
+ record
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # C4 — the incumbent's half of a shadow pair, recorded on the mirror's
8
+ # word. One command, two doors: Shape 1 (the reply rides the mirror call
9
+ # itself) and Shape 2 (the follow-up route, for a consumer that mirrors
10
+ # before answering) both dispatch HERE, so there is one behaviour and one
11
+ # store method behind both.
12
+ #
13
+ # Idempotency is first-write-wins: the customer received ONE reply, and
14
+ # letting a retry overwrite it would silently rewrite evidence. The
15
+ # guarantee lives in ShadowPairStore#record_incumbent (inside its
16
+ # transaction); the find below is only the cheap fast path for the common
17
+ # retry. A reply for a pair that does not exist yet creates it :open — the
18
+ # mirror may legitimately arrive before our turn finishes.
19
+ class RecordShadowReply
20
+ def initialize(shadow_pairs:, event_stream: nil)
21
+ @shadow_pairs = shadow_pairs
22
+ @event_stream = event_stream
23
+ end
24
+
25
+ # payload: { channel:, external_id:, event_id:, reply:, at: }
26
+ # -> { pair_id:, status: }
27
+ def call(command)
28
+ payload = command.payload
29
+ channel = Coercion.presence(payload[:channel] || payload["channel"])
30
+ external_id = Coercion.presence(payload[:external_id] || payload["external_id"])
31
+ event_id = Coercion.presence(payload[:event_id] || payload["event_id"])
32
+ reply = payload[:reply] || payload["reply"]
33
+
34
+ raise ValidationError, "channel is required" if channel.nil?
35
+ raise ValidationError, "external_id is required" if external_id.nil?
36
+ raise ValidationError, "event_id is required" if event_id.nil?
37
+ raise ValidationError, "reply is required" if reply.to_s.strip.empty?
38
+
39
+ id = ShadowPairStore.key_for(channel: channel, external_id: external_id,
40
+ event_id: event_id)
41
+ existing = @shadow_pairs.find(id)
42
+ if existing && !existing.incumbent_reply.nil?
43
+ emit(id, "already_recorded")
44
+ return { pair_id: id, status: "already_recorded" }
45
+ end
46
+
47
+ pair = @shadow_pairs.record_incumbent(id: id, channel: channel, event_id: event_id,
48
+ external_id: external_id, reply: reply.to_s,
49
+ at: payload[:at] || payload["at"])
50
+ emit(id, pair.status.to_s)
51
+ { pair_id: id, status: pair.status.to_s }
52
+ end
53
+
54
+ private
55
+
56
+ # Metadata only: the pair id and its status, never the reply's text.
57
+ def emit(pair_id, status)
58
+ return unless @event_stream
59
+
60
+ @event_stream.emit(Insika::Event.new(
61
+ type: :shadow_reply_recorded,
62
+ data: { pair_id: pair_id, status: status },
63
+ meta: { at: Time.now.utc.iso8601 }
64
+ ))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: LIVE re-discovery of an MCP instance's tools.
8
+ # Delegates to McpToolRegistry#refresh (connect +
9
+ # tools/list + write McpStore#tools_cache) — TOOL EXECUTION never reads
10
+ # that cache; this command only refreshes what `entries`/Studio/doctor
11
+ # display. The CLI verb / API route / Studio button that call it are
12
+ # later work (PR3/PR4) — the command itself is the seam they'll share.
13
+ # -> { instance:, tools: [{name,description,inputSchema}, ...] }.
14
+ class RefreshMcpTools
15
+ def initialize(mcp_registry:, event_stream:)
16
+ @mcp_registry = mcp_registry
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = symbolize(command.payload)
22
+ name = Insika::Coercion.presence(p[:name]) ||
23
+ (raise Insika::ValidationError, "refresh_mcp_tools: 'name' (MCP instance) is required")
24
+
25
+ tools = @mcp_registry.refresh(name)
26
+ emit(name, tools)
27
+ { instance: name, tools: tools }
28
+ end
29
+
30
+ private
31
+
32
+ # Emits only the COUNT + the instance name (never a tool's schema, in
33
+ # case a schema description ever carries something sensitive): 0 leakage.
34
+ def emit(name, tools)
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :mcp_tools_refreshed,
37
+ data: { instance: name, tools: tools.size },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ end
41
+
42
+ def symbolize(payload)
43
+ (payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the operator's dismissal: a candidate that is NOT yet
8
+ # gated can be rejected by a human directly (a human may always outvote
9
+ # the miner). The thin wrapper over `mark_rejected`.
10
+ class RejectHarvest
11
+ def initialize(harvest_store:, event_stream:)
12
+ @harvest_store = harvest_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ # payload: { candidate_id:, operator?, note? }
17
+ # -> Candidate (rejected)
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ candidate_id = AgentPayload.presence(p[:candidate_id])
21
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
22
+
23
+ candidate = @harvest_store.find_candidate(candidate_id) ||
24
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
25
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
26
+ rejected = @harvest_store.mark_rejected(candidate.id, operator: operator, note: p[:note])
27
+
28
+ @event_stream.emit(Insika::Event.new(
29
+ type: :harvest_rejected,
30
+ data: { candidate_id: candidate.id, agent: candidate.agent,
31
+ operator: operator },
32
+ meta: { at: Time.now.utc.iso8601 }
33
+ ))
34
+ rejected
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # the human's answer : approve (→ the
6
+ # store, CAS, provenance `distilled:<session_ref>`), reject (with an
7
+ # optional reason), dismiss (latches the tuple). Approval never silently
8
+ # overwrites an operator edit (D5/E3): the CAS baseline captured at distill
9
+ # time guards the write, and a lost race flips the proposal to `:stale`
10
+ # carrying the fact's CURRENT value — both values visible on the wiki.
11
+ # Synchronous control command — the Studio dispatches it; no task, no actor.
12
+ class ResolveProposal
13
+ DECISIONS = %w[approved rejected dismissed].freeze
14
+
15
+ def initialize(proposal_store:, memory_store:, event_stream:)
16
+ @proposal_store = proposal_store
17
+ @memory_store = memory_store
18
+ @event_stream = event_stream
19
+ end
20
+
21
+ # payload: { proposal_id:, decision: "approved"|"rejected"|"dismissed",
22
+ # operator?, note? }
23
+ # operator from payload || command.meta[:operator] || "operator"
24
+ # -> Proposal (the resolved record; a :stale one carries current_value)
25
+ def call(command)
26
+ proposal_id = Coercion.presence(command.payload[:proposal_id] || command.payload["proposal_id"])
27
+ raise ValidationError, "proposal_id is required" if proposal_id.nil?
28
+
29
+ decision = Coercion.presence(command.payload[:decision] || command.payload["decision"])
30
+ raise ValidationError, "decision is required" unless DECISIONS.include?(decision)
31
+
32
+ proposal = @proposal_store.find(proposal_id)
33
+ raise Insika::NotFoundError, "proposal not found: #{proposal_id}" if proposal.nil?
34
+
35
+ operator = operator_for(command)
36
+ note = Coercion.presence(command.payload[:note] || command.payload["note"])
37
+
38
+ case decision
39
+ when "approved" then approve(command, proposal, operator, note)
40
+ when "rejected"
41
+ resolved = @proposal_store.reject(id: proposal.id, operator: operator, note: note)
42
+ emit(:proposal_rejected, resolved)
43
+ resolved
44
+ else # "dismissed"
45
+ resolved = @proposal_store.dismiss(id: proposal.id, operator: operator, note: note)
46
+ emit(:proposal_dismissed, resolved)
47
+ resolved
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # The whole safety argument (D5/E3). The read-check-write CAS rides the
54
+ # store's transaction — a lost race is a re-present, never a silent
55
+ # overwrite.
56
+ def approve(command, proposal, operator, note)
57
+ fact = @memory_store.get_fact(tenant: proposal.tenant, customer: proposal.customer,
58
+ key: proposal.key)
59
+ if proposal.expected_existed
60
+ if fact && fact.updated_at == proposal.expected_revision
61
+ written = @memory_store.replace_if_revision(
62
+ tenant: proposal.tenant, customer: proposal.customer,
63
+ key: proposal.key, value: proposal.value,
64
+ expected_revision: proposal.expected_revision,
65
+ origin: "distilled:#{proposal.session_ref}")
66
+ return stale(proposal, fact, operator) unless written
67
+ else
68
+ return stale(proposal, fact, operator) # the operator's edit stands
69
+ end
70
+ elsif fact
71
+ return stale(proposal, fact, operator) # the operator created it first
72
+ else
73
+ @memory_store.put_fact(tenant: proposal.tenant, customer: proposal.customer,
74
+ key: proposal.key, value: proposal.value,
75
+ origin: "distilled:#{proposal.session_ref}")
76
+ end
77
+ resolved = @proposal_store.approve(id: proposal.id, operator: operator, note: note)
78
+ emit(:proposal_approved, resolved)
79
+ resolved
80
+ end
81
+
82
+ # The re-present (E3): the proposal carries the fact's CURRENT value next
83
+ # to the proposed one; both are visible on the wiki page.
84
+ def stale(proposal, fact, operator)
85
+ resolved = @proposal_store.mark_stale(id: proposal.id, current_value: fact&.value,
86
+ operator: operator)
87
+ emit(:proposal_stale, resolved)
88
+ resolved
89
+ end
90
+
91
+ # ids and statuses only (D7) — a fact value never enters the stream.
92
+ def emit(type, proposal)
93
+ @event_stream.emit(Insika::Event.new(
94
+ type: type,
95
+ data: { proposal_id: proposal.id, status: proposal.status,
96
+ operator: proposal.operator },
97
+ meta: { at: Time.now.utc.iso8601 }
98
+ ))
99
+ end
100
+
101
+ def operator_for(command)
102
+ Coercion.presence(command.payload[:operator] || command.payload["operator"]) ||
103
+ Coercion.presence(command.meta[:operator]) ||
104
+ "operator"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: restores an old version of a concept as the current
8
+ # content (a new write — linear history, like `RestoreSystemFile`).
9
+ # -> { name, agent, tenant, updated_at }.
10
+ class RestoreConcept
11
+ def initialize(knowledge_store:, event_stream:)
12
+ @knowledge_store = knowledge_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ name = AgentPayload.presence(p[:name])
19
+ agent = AgentPayload.presence(p[:agent])
20
+ tenant = AgentPayload.presence(p[:tenant])
21
+ raise Insika::ValidationError, "name is required" if name.nil?
22
+ raise Insika::ValidationError, "agent is required" if agent.nil?
23
+ raise Insika::ValidationError, "version is required" if p[:version].nil?
24
+
25
+ rec = @knowledge_store.restore(agent, name, p[:version], tenant: tenant)
26
+ @event_stream.emit(Insika::Event.new(
27
+ type: :knowledge_learned, data: { name: name, agent: agent },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ { name: name, agent: agent, tenant: tenant, updated_at: rec["updated_at"] }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # a channel opt-out event (mapped by the integration)— or
8
+ # the operator force-revoking a contact whose opt-out never arrived.
9
+ # Revokes the contact cell AND cancels every pending record of that
10
+ # customer in ONE transaction on the shared backend (D2: a half-cancelled
11
+ # opt-out is the spam bug — a crash mid-revoke must leave the old state,
12
+ # never the cell revoked with pending records alive). Blocked/fired
13
+ # records are never touched. Tenant-scoped (WS1).
14
+ class RevokeContact
15
+ def initialize(contact_store:, followup_store:, store:, event_stream:)
16
+ @contact_store = contact_store
17
+ @followup_store = followup_store
18
+ @store = store
19
+ @event_stream = event_stream
20
+ end
21
+
22
+ # -> { customer:, tenant:, cancelled: }.
23
+ def call(command)
24
+ customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
25
+ raise ValidationError, "customer is required" if customer.nil?
26
+
27
+ tenant = command.meta[:tenant]
28
+ # ONE transaction on the shared backend: both stores' writes (each
29
+ # wrapped in their own nested transaction) join the outer one.
30
+ cancelled = @store.transaction do
31
+ @contact_store.set_revoked(tenant: tenant, customer: customer)
32
+ @followup_store.cancel_pending_for(tenant: tenant, customer: customer)
33
+ end
34
+
35
+ @event_stream.emit(Insika::Event.new(
36
+ type: :contact_revoked,
37
+ data: { customer: customer, tenant: tenant },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ @event_stream.emit(Insika::Event.new(
41
+ type: :followups_cancelled,
42
+ data: { customer: customer, count: cancelled },
43
+ meta: { tenant: tenant, at: Time.now.utc.iso8601 }
44
+ ))
45
+ { customer: customer, tenant: tenant, cancelled: cancelled }
46
+ end
47
+ end
48
+ end
49
+ end