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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "securerandom"
5
+
6
+ module Insika
7
+ module Commands
8
+ # — the snapshot restored. `RollbackHarvest(snapshot_ref:)`
9
+ # restores the pre-promotion state deterministically: WriteSkill with the
10
+ # snapshot's pre-promotion content (or DeleteSkill when the skill did not
11
+ # exist before — the snapshot's `existed` flag), SetSkillAgents to the
12
+ # snapshot's pre-promotion `enabled_for`, and the `rolled_back_at` stamp on
13
+ # the SAME log (the log stays the single ledger: a skill promoted, rolled
14
+ # back, re-promoted is three readable rows). The "rehearsed once
15
+ # end-to-end" acceptance is this command's full promote -> rollback cycle.
16
+ class RollbackHarvest
17
+ def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
18
+ event_stream:, write_skill: nil, delete_skill: nil, set_skill_agents: nil)
19
+ @harvest_store = harvest_store
20
+ @profiles = ProfileSource.coerce(profile_source)
21
+ @event_stream = event_stream
22
+ @write_skill = write_skill ||
23
+ Insika::Commands::WriteSkill.new(skill_store: skill_store,
24
+ skill_catalog: skill_catalog,
25
+ event_stream: event_stream)
26
+ @delete_skill = delete_skill ||
27
+ Insika::Commands::DeleteSkill.new(skill_store: skill_store,
28
+ skill_catalog: skill_catalog,
29
+ event_stream: event_stream)
30
+ @set_skill_agents = set_skill_agents ||
31
+ Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
32
+ event_stream: event_stream)
33
+ end
34
+
35
+ # payload: { snapshot_ref:, operator?, reason? }
36
+ # -> Promotion (the row with rolled_back_at stamped)
37
+ def call(command)
38
+ p = AgentPayload.symbolize(command.payload)
39
+ snapshot_ref = AgentPayload.presence(p[:snapshot_ref])
40
+ raise Insika::ValidationError, "snapshot_ref is required" if snapshot_ref.nil?
41
+
42
+ snap = @harvest_store.find_snapshot(snapshot_ref) ||
43
+ (raise Insika::NotFoundError, "harvest snapshot not found: #{snapshot_ref}")
44
+ # The log row whose snapshot_ref matches — but a promotion that never
45
+ # recorded its row (a crash between the writes and the append) rolls
46
+ # back by snapshot, and the rollback stamp catches up on whatever row
47
+ # exists (D8's honest price, resolved by reference).
48
+ promotion = promotion_for_snapshot(snap)
49
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
50
+
51
+ if snap.existed
52
+ @write_skill.call(Insika::Command.build(:write_skill,
53
+ { name: snap.skill, content: snap.content,
54
+ agent: snap.agent }))
55
+ else
56
+ begin
57
+ @delete_skill.call(Insika::Command.build(:delete_skill,
58
+ { name: snap.skill, agent: snap.agent }))
59
+ rescue Insika::NotFoundError
60
+ # the desired state (gone) already holds — a snapshot taken for a
61
+ # skill that never landed rolls back cleanly
62
+ end
63
+ end
64
+ @set_skill_agents.call(Insika::Command.build(:set_skill_agents,
65
+ { name: snap.skill,
66
+ agent_ids: snap.enabled_for }))
67
+
68
+ rolled = promotion ? @harvest_store.append_rollback(promotion_id: promotion.id,
69
+ operator: operator, reason: p[:reason]) : nil
70
+ @event_stream.emit(Insika::Event.new(
71
+ type: :skill_rolled_back,
72
+ data: { snapshot_ref: snapshot_ref, skill: snap.skill,
73
+ agent: snap.agent, operator: operator }.compact,
74
+ meta: { at: Time.now.utc.iso8601 }
75
+ ))
76
+ rolled || promotion
77
+ end
78
+
79
+ private
80
+
81
+ def promotion_for_snapshot(snap)
82
+ @harvest_store.promotions.find { |row| row.snapshot_ref == snap.id }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # the ONLY path that writes proposals. Distills ONE session
6
+ # end to end: read the transcript and the memory baseline, ask the model,
7
+ # schema-drop, dedup against the ledger, write proposals, mark the session
8
+ # distilled. Synchronous (it runs on the engine's worker fiber, C5) — it
9
+ # creates no task and no turn (D2: no TaskStore work unit — a crash
10
+ # mid-pass leaves the marker unwritten and the next pass re-scans; a
11
+ # duplicate proposal is filtered by the ledger, never applied).
12
+ class RunDistillation
13
+ DEFAULT_IDLE_HOURS = 6
14
+ DEFAULT_MIN_MESSAGES = 3
15
+ DEFAULT_MAX_PROPOSALS = 10
16
+
17
+ def initialize(profiles:, proposal_store:, session_store:, memory_store:,
18
+ settings_store:, event_stream:, distiller_factory: nil)
19
+ @profiles = profiles
20
+ @proposal_store = proposal_store
21
+ @session_store = session_store
22
+ @memory_store = memory_store
23
+ @settings_store = settings_store
24
+ @event_stream = event_stream
25
+ @distiller_factory = distiller_factory ||
26
+ ->(config) { Distill::DistillerFactory.build(config, utility_model: utility_model) }
27
+ end
28
+
29
+ # payload: { session_id: } (agent resolved from the session, D6-bis)
30
+ # -> { distilled: true, proposals: N, dropped: {...}, deduped: N, cost: {...} | nil }
31
+ # | { distilled: false, skipped: "already|untagged|no_agent|disabled|
32
+ # too_fresh|too_short|no_model" }
33
+ def call(command)
34
+ session_id = Coercion.presence(command.payload[:session_id] || command.payload["session_id"])
35
+ raise ValidationError, "session_id is required" if session_id.nil?
36
+
37
+ session = @session_store.find(session_id)
38
+ raise Insika::NotFoundError, "session not found: #{session_id}" if session.nil?
39
+
40
+ return skip("already") if @proposal_store.distilled?(session_id)
41
+
42
+ customer = Coercion.presence(session.vars["customer"])
43
+ return skip("untagged") if customer.nil?
44
+
45
+ agent_id = Coercion.presence(session.vars["agent"])
46
+ return skip("no_agent") if agent_id.nil?
47
+
48
+ profile = @profiles[agent_id]
49
+ return skip("no_agent") if profile.nil?
50
+
51
+ config = Coercion.deep_stringify(profile.distill)
52
+ return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
53
+
54
+ # D4: the pack's own idle threshold wins over the scan's lower bound —
55
+ # a pack that wants 12 h is never distilled at 6.
56
+ idle_hours = config["idle_hours"].to_i
57
+ idle_hours = DEFAULT_IDLE_HOURS unless idle_hours.positive?
58
+ return skip("too_fresh") unless idle?(session.updated_at, idle_hours)
59
+
60
+ min_messages = config["min_messages"].to_i
61
+ min_messages = DEFAULT_MIN_MESSAGES unless min_messages.positive?
62
+ return skip("too_short") if session.messages.size < min_messages
63
+
64
+ tenant = tenant_of(session_id)
65
+ distiller = @distiller_factory.call(config)
66
+ return skip("no_model") if distiller.nil?
67
+
68
+ baseline = memory_baseline(tenant, customer)
69
+ prompt = build_prompt(config, session, baseline)
70
+ result = distiller.distill(prompt: prompt, message_count: session.messages.size,
71
+ max_proposals: (config["max_proposals"] || DEFAULT_MAX_PROPOSALS).to_i)
72
+
73
+ deduped = 0
74
+ survivors = result[:proposals].reject do |proposal|
75
+ dedup = deduped?(tenant, customer, proposal, baseline)
76
+ deduped += 1 if dedup
77
+ dedup
78
+ end
79
+
80
+ survivors.each do |proposal|
81
+ expected = baseline[proposal["name"]]
82
+ @proposal_store.create(
83
+ tenant: tenant, customer: customer, session_ref: session_id,
84
+ key: proposal["name"], value: proposal["value"],
85
+ confidence: proposal["confidence"], evidence: proposal["turns"],
86
+ expected_revision: expected && expected.updated_at,
87
+ expected_existed: !expected.nil?
88
+ )
89
+ end
90
+
91
+ @proposal_store.mark_distilled(session_id, agent: agent_id,
92
+ proposals: survivors.size,
93
+ dropped: result[:dropped],
94
+ deduped: deduped,
95
+ cost: result[:cost])
96
+ @event_stream.emit(Insika::Event.new(
97
+ type: :distillation_completed,
98
+ # counts and ids only (D7) — a fact value never
99
+ # enters the stream.
100
+ data: { session_ref: session_id, agent: agent_id,
101
+ proposals: survivors.size,
102
+ dropped: result[:dropped],
103
+ deduped: deduped,
104
+ cost: result[:cost] },
105
+ meta: { at: Time.now.utc.iso8601 }
106
+ ))
107
+ { distilled: true, proposals: survivors.size, dropped: result[:dropped],
108
+ deduped: deduped, cost: result[:cost] }
109
+ end
110
+
111
+ private
112
+
113
+ # nil tenant in single-tenant deployments; the "<tenant>:" prefix of the
114
+ # session id otherwise (D1 — the same reading ForgetCustomer uses).
115
+ def tenant_of(session_id)
116
+ tenant, rest = session_id.to_s.split(":", 2)
117
+ rest.nil? ? nil : tenant
118
+ end
119
+
120
+ def skip(reason)
121
+ { distilled: false, skipped: reason }
122
+ end
123
+
124
+ def idle?(updated_at, idle_hours)
125
+ return false if Coercion.blank?(updated_at)
126
+
127
+ Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
128
+ rescue ArgumentError
129
+ false
130
+ end
131
+
132
+ # Per fact key: the existence + updated_at at distill time (D5 — the CAS
133
+ # baseline the approval compares against). Sorted by key for a stable
134
+ # prompt.
135
+ def memory_baseline(tenant, customer)
136
+ @memory_store.facts(tenant: tenant, customer: customer)
137
+ .each_with_object({}) { |f, acc| acc[f.key] = f }
138
+ end
139
+
140
+ def deduped?(tenant, customer, proposal, baseline)
141
+ key = proposal["name"].to_s
142
+ value = proposal["value"].to_s
143
+ return true if @proposal_store.decided?(tenant: tenant, customer: customer,
144
+ key: key, value: value)
145
+ return true if @proposal_store.open_pending?(tenant: tenant, customer: customer, key: key)
146
+
147
+ applied = baseline[key]
148
+ !applied.nil? && applied.value == value && applied.origin.to_s.start_with?("distilled:")
149
+ end
150
+
151
+ # The prompt gets the transcript slice (masked through the
152
+ # output filter first — the redaction rule), the
153
+ # customer's CURRENT facts (so the model can avoid re-proposing applied
154
+ # facts), and the answer rules (the pack prompt or DEFAULT_PROMPT).
155
+ def build_prompt(config, session, baseline)
156
+ base = Coercion.presence(config["prompt"]) || Distill::DEFAULT_PROMPT
157
+ transcript = render_transcript(session.messages)
158
+ facts = baseline.values.map { |f| "- #{f.key}: #{f.value}" }
159
+ <<~PROMPT
160
+ #{base.rstrip}
161
+
162
+ ## The conversation
163
+
164
+ #{transcript}
165
+
166
+ ## Facts already in this customer's memory (do not re-propose them)
167
+
168
+ #{facts.empty? ? "(none)" : facts.join("\n")}
169
+ PROMPT
170
+ end
171
+
172
+ def render_transcript(messages)
173
+ redacted, = Insika::Safety::Detectors.redact(
174
+ messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
175
+ )
176
+ redacted
177
+ end
178
+
179
+ def utility_model
180
+ return nil unless @settings_store
181
+
182
+ @settings_store.get["utility_model"]
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # the ONLY path that writes candidates. Mines ONE window end
8
+ # to end: resolve the sessions, read the transcripts + evidence, ask the
9
+ # miner, schema-drop, apply the negative list and the grounding filter,
10
+ # dedup against the ledger, write the run + candidates, stamp the markers.
11
+ #
12
+ # Synchronous (it runs on the engine's worker fiber, C12, or the CLI, C15)
13
+ # — it creates no task and no turn. It writes NOTHING to sessions or
14
+ # skills: D2's fork discipline is this command's contract — a customer
15
+ # turn's prefix is untouched by construction, and the only harvest-side
16
+ # spend is the run's cost (E1).
17
+ #
18
+ # Payload: { agent:, last_sessions?, since?, full?, session_ids?,
19
+ # max_proposals?, exclude_sessions? }
20
+ class RunHarvest
21
+ DEFAULT_LAST_SESSIONS = 200 # the EvidenceCollector's window default
22
+ DEFAULT_MIN_MESSAGES = 3
23
+ DEFAULT_MAX_PROPOSALS = 10
24
+ DEFAULT_IDLE_HOURS = 24
25
+
26
+ def initialize(profiles:, harvest_store:, session_store:, task_store:,
27
+ skill_store: nil, tool_trace_store: nil, settings_store: nil,
28
+ negative_list: nil, miner_factory: nil, event_stream:)
29
+ @profiles = ProfileSource.coerce(profiles)
30
+ @harvest_store = harvest_store
31
+ @session_store = session_store
32
+ @task_store = task_store
33
+ @skill_store = skill_store
34
+ @tool_trace_store = tool_trace_store
35
+ @settings_store = settings_store
36
+ @negative_list = negative_list
37
+ @miner_factory = miner_factory ||
38
+ ->(config) { Harvest::MinerFactory.build(config, utility_model: utility_model) }
39
+ @event_stream = event_stream
40
+ end
41
+
42
+ # -> { mined: true, run_id:, candidates: N,
43
+ # rejected: { "<rule-id>" => N, "ungrounded" => N, "dedup" => N,
44
+ # "schema" => N, ... }, cost: {...} | nil }
45
+ # | { mined: false, skipped: "disabled|no_model|no_grounding_matcher" }
46
+ def call(command)
47
+ p = AgentPayload.symbolize(command.payload)
48
+ agent = AgentPayload.presence(p[:agent])
49
+ raise Insika::ValidationError, "agent is required" if agent.nil?
50
+
51
+ profile = @profiles[agent] ||
52
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
53
+ config = Coercion.deep_stringify(profile.harvest)
54
+ return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
55
+
56
+ # D4: the OPERATIVE negative list lives on the profile (hot-editable,
57
+ # seeded by `insika harvest:negative import`); the injected list is
58
+ # the deployment's fallback. The engine applies data, never authors it.
59
+ @list = negative_list_for(config)
60
+
61
+ # D3: product claims cannot be verified without a matcher, so NOTHING
62
+ # mines — refused, not warned (D12's "by refusal, not by prompt").
63
+ grounding = Grounding.parse(profile.grounding)
64
+ return skip("no_grounding_matcher") if grounding.nil? || !grounding.matcher.sku?
65
+
66
+ miner = @miner_factory.call(config)
67
+ return skip("no_model") if miner.nil?
68
+
69
+ run = @harvest_store.create_run(agent_id: agent, window: window_record(p),
70
+ budget: budget_cap(config))
71
+ sessions = resolve_sessions(agent, p, config)
72
+
73
+ begin
74
+ # No eligible sessions: a run that says "we looked and it was clean"
75
+ # without paying a bill; markers untouched (re-scan, D10).
76
+ if sessions.empty?
77
+ @harvest_store.complete_run(run.id, candidates: 0)
78
+ return { mined: true, run_id: run.id, candidates: 0,
79
+ rejected: empty_rejected, cost: nil }
80
+ end
81
+
82
+ prompt = build_prompt(config, sessions, agent)
83
+ result = miner.mine(prompt: prompt,
84
+ message_counts: sessions.map { |s| s[:messages].size },
85
+ max_proposals: max_proposals(p, config))
86
+
87
+ # The mining budget is a REAL cap (the review fix): a pass that
88
+ # spent more than the pack declared is failed with the numbers and
89
+ # proposes NOTHING — the docs' "the budget cap bounds it" is
90
+ # enforced here, post-hoc for the one model call, pre-hoc for every
91
+ # downstream write and gate.
92
+ if budget_exceeded?(budget_cap(config), result[:cost])
93
+ @harvest_store.fail_run(run.id, error: "mining budget exceeded: " \
94
+ "spent #{result[:cost]['spent']} > " \
95
+ "#{budget_cap(config)['tokens']}")
96
+ emit(:harvest_mined, agent: agent, run_id: run.id, candidates: 0,
97
+ rejected: empty_rejected, cost: result[:cost])
98
+ return { mined: true, run_id: run.id, candidates: 0,
99
+ rejected: empty_rejected, cost: result[:cost] }
100
+ end
101
+
102
+ survivors, rejected = filter_skills(result[:skills], sessions, agent, grounding.matcher)
103
+
104
+ survivors.each do |skill|
105
+ @harvest_store.create_candidate(
106
+ run_id: run.id, agent: agent, name: skill["name"],
107
+ description: skill["description"], body: skill_md(skill),
108
+ triggers: skill["triggers"] || [], rationale: skill["rationale"].to_s,
109
+ origin: sessions.map { |s| s[:id] }, evidence_turns: skill["evidence_turns"] || [],
110
+ proposer: miner.model
111
+ )
112
+ end
113
+
114
+ final_rejected = merge_rejected(result[:dropped], rejected)
115
+ @harvest_store.complete_run(run.id, candidates: survivors.size,
116
+ cost: result[:cost],
117
+ rejected: final_rejected)
118
+ # Markers AFTER the pass completes (D10's crash-safe re-scan).
119
+ sessions.each { |s| @harvest_store.mark_mined(s[:id], candidates: survivors.size) }
120
+
121
+ emit(:harvest_mined, agent: agent, run_id: run.id, candidates: survivors.size,
122
+ rejected: final_rejected, cost: result[:cost])
123
+ { mined: true, run_id: run.id, candidates: survivors.size,
124
+ rejected: final_rejected, cost: result[:cost] }
125
+ rescue StandardError => e
126
+ # The run is failed; the markers are NOT written (re-scan, D10);
127
+ # the exception propagates to the caller's fiber or the CLI.
128
+ begin
129
+ @harvest_store.fail_run(run.id, error: e.message)
130
+ rescue StandardError
131
+ nil
132
+ end
133
+ raise
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def skip(reason)
140
+ { mined: false, skipped: reason }
141
+ end
142
+
143
+ # The pack's harvest.miner.budget as { "tokens" => N } — nil when
144
+ # absent/not positive (no cap, the refinement discipline).
145
+ def budget_cap(config)
146
+ raw = config && config.dig("miner", "budget")
147
+ raw.is_a?(Hash) && raw["tokens"].to_i.positive? ? { "tokens" => raw["tokens"].to_i } : nil
148
+ end
149
+
150
+ def budget_exceeded?(cap, cost)
151
+ cap && cost && cost["spent"].to_i > cap["tokens"].to_i
152
+ end
153
+
154
+ # The candidate's body is a full SKILL.md by construction: the model
155
+ # writes the procedure, the engine wraps the frontmatter (name /
156
+ # description / triggers — the miner's own fields), so a candidate is
157
+ # servable the moment a human promotes it (WriteSkill validates the
158
+ # frontmatter, and the gate's clone serves it the same way).
159
+ def skill_md(skill)
160
+ fm = { "name" => skill["name"], "description" => skill["description"] }
161
+ fm["triggers"] = Array(skill["triggers"]).join(", ") if Array(skill["triggers"]).any?
162
+ "---\n#{fm.map { |k, v| "#{k}: #{v}" }.join("\n")}\n---\n#{skill['body']}"
163
+ end
164
+
165
+ # The profile's own rules win over the injected seed (D4). A malformed
166
+ # profile list parses to nil — the injected list stays the fallback,
167
+ # never a silent empty.
168
+ def negative_list_for(config)
169
+ return @negative_list unless config && config["negative_list"]
170
+
171
+ Harvest::NegativeList.parse(config["negative_list"]) || @negative_list
172
+ end
173
+
174
+ def empty_rejected = { "schema" => 0, "unknown_key" => 0, "oversized" => 0,
175
+ "bad_turns" => 0, "duplicate" => 0, "capped" => 0 }
176
+
177
+ def window_record(p)
178
+ return { "session_ids" => Array(p[:session_ids]).map(&:to_s) } if p[:session_ids]
179
+ return { "since" => p[:since].to_s } if AgentPayload.presence(p[:since])
180
+ return { "last_sessions" => Integer(p[:last_sessions]) } if p[:last_sessions]
181
+
182
+ {}
183
+ end
184
+
185
+ # The EvidenceCollector window discipline, applied to session ids: the
186
+ # agent's turns (by the task's command payload), distinct session ids,
187
+ # newest first, capped at the "last N conversations". Marker suppression
188
+ # unless `full` (a re-mine is explicit). A session's evidence ledger
189
+ # is the grounding filter's input.
190
+ def resolve_sessions(agent, p, config)
191
+ ids = window_session_ids(agent, p, config)
192
+ ids = ids.reject { |sid| @harvest_store.mined?(sid) } unless EnvSchema.truthy?(p[:full])
193
+ ids = ids.first(Harvest::Miner::MAX_SESSIONS)
194
+
195
+ min_messages = (config["min_messages"] || DEFAULT_MIN_MESSAGES).to_i
196
+ idle_hours = (config["idle_hours"] || DEFAULT_IDLE_HOURS).to_i
197
+ ids.filter_map do |sid|
198
+ session = @session_store.find(sid)
199
+ next unless session
200
+
201
+ messages = session.messages.to_a
202
+ next if messages.size < min_messages # a 2-message session mines noise
203
+ # D10: the per-agent maturity bound is re-checked here — the engine
204
+ # scan's default is only the LOWER bound (a pack that wants 12 h is
205
+ # never mined at 6, and a manual CLI run over fresh traffic mines
206
+ # nothing).
207
+ next unless idle?(session.updated_at, idle_hours)
208
+
209
+ evidence = (session.evidence || {})["ids"] || []
210
+ { id: sid.to_s, messages: messages, evidence: Array(evidence).map(&:to_s) }
211
+ end
212
+ end
213
+
214
+ def window_session_ids(agent, p, config)
215
+ explicit = Array(p[:session_ids]).map(&:to_s)
216
+ return explicit unless explicit.empty?
217
+
218
+ since = AgentPayload.presence(p[:since])
219
+ tasks = @task_store.each_id
220
+ .filter_map { |id| @task_store.find(id) }
221
+ .select { |t| task_agent(t) == agent }
222
+ .sort_by { |t| [t.created_at.to_s, t.id] }.reverse
223
+
224
+ tasks = tasks.select { |t| t.created_at.to_s >= since.to_s } if since
225
+
226
+ distinct = []
227
+ seen = {}
228
+ tasks.each do |t|
229
+ sid = presence(t.session_id)
230
+ next if sid.nil? || seen[sid]
231
+
232
+ seen[sid] = true
233
+ distinct << sid
234
+ end
235
+ return distinct if since
236
+
237
+ last = AgentPayload.presence(p[:last_sessions])
238
+ count = last ? Integer(last) : config_window(config)
239
+ distinct.first(count)
240
+ end
241
+
242
+ # The config's miner.window last_sessions, or the collector default.
243
+ def config_window(config)
244
+ configured = config.dig("miner", "window", "last_sessions")
245
+ configured ? Integer(configured) : DEFAULT_LAST_SESSIONS
246
+ end
247
+
248
+ def task_agent(task)
249
+ task.command.is_a?(Hash) ? task.command.dig("payload", "agent").to_s : ""
250
+ end
251
+
252
+ def idle?(updated_at, idle_hours)
253
+ return false if Coercion.blank?(updated_at)
254
+
255
+ Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
256
+ rescue ArgumentError
257
+ false
258
+ end
259
+
260
+ def presence(value)
261
+ Insika::Coercion.presence(value)
262
+ end
263
+
264
+ def max_proposals(p, config)
265
+ raw = p[:max_proposals] || config.dig("miner", "max_proposals")
266
+ raw ? Integer(raw) : DEFAULT_MAX_PROPOSALS
267
+ end
268
+
269
+ # The prompt: the transcript slices (masked through the output
270
+ # filter — the redaction rule), the evidence ids per
271
+ # session, the agent's CURRENT skill names (the model should not
272
+ # re-propose them), and the answer rules. The pack prompt replaces
273
+ # DEFAULT_PROMPT whole (the forge's half).
274
+ def build_prompt(config, sessions, agent)
275
+ base = Coercion.presence(config["prompt"]) || Harvest::DEFAULT_PROMPT
276
+ current_skills = current_skill_names(agent)
277
+ blocks = sessions.map do |s|
278
+ evidence = s[:evidence].join(", ")
279
+ lines = ["## Session #{s[:id]} (#{s[:messages].size} messages; " \
280
+ "evidence ids: #{evidence.empty? ? '(none)' : evidence})"]
281
+ lines << render_transcript(s[:messages])
282
+ lines.join("\n")
283
+ end
284
+ <<~PROMPT
285
+ #{base.rstrip}
286
+
287
+ ## Conversations to mine
288
+
289
+ #{blocks.join("\n")}
290
+
291
+ ## Skills this agent already has (do not re-propose them)
292
+
293
+ #{current_skills.empty? ? "(none)" : current_skills.join(", ")}
294
+ PROMPT
295
+ end
296
+
297
+ def current_skill_names(agent)
298
+ return [] unless @skill_store
299
+
300
+ (@skill_store.names | @skill_store.names(agent: agent)).sort
301
+ end
302
+
303
+ def render_transcript(messages)
304
+ redacted, = Insika::Safety::Detectors.redact(
305
+ messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
306
+ )
307
+ redacted
308
+ end
309
+
310
+ # The safety order: negative list (D4) -> grounding (D3) -> dedup. A
311
+ # drop is COUNTED and NEVER written. `rejected` keys: the matched RULE
312
+ # ids for negative hits (each named — E2), "ungrounded"/"dedup" for the
313
+ # other two filters.
314
+ # -> [[raw skill], { key => count }]
315
+ def filter_skills(skills, sessions, agent, matcher)
316
+ rejected = Hash.new(0)
317
+ survivors = skills.select do |skill|
318
+ hits = negative_hits(skill)
319
+ if hits.any?
320
+ hits.each { |r| rejected[r.rule] += 1 }
321
+ false
322
+ elsif !grounded?(skill, sessions, matcher)
323
+ rejected["ungrounded"] += 1
324
+ false
325
+ elsif deduped?(skill, agent)
326
+ rejected["dedup"] += 1
327
+ false
328
+ else
329
+ true
330
+ end
331
+ end
332
+ [survivors, rejected.to_h]
333
+ end
334
+
335
+ # D4: every rejected-by-list candidate is logged with the matching rule
336
+ # id. The BODY is part of the match (the review fix): it is exactly what
337
+ # enters the model's context when the skill loads, so a banned phrase
338
+ # hidden there is the Hermes failure the design cites — the same text
339
+ # the grounding filter already reads (name+description+body). The name
340
+ # uses the stricter substring reading, the prose the word-boundary one.
341
+ def negative_hits(skill)
342
+ return [] unless @list
343
+
344
+ text = [skill["name"], skill["description"], skill["body"]].join(" ")
345
+ hits = @list.matches_name(skill["name"].to_s)
346
+ hits | @list.matches(text)
347
+ end
348
+
349
+ # D3: every reference must be in the union of the origin sessions'
350
+ # persisted evidence ids. The empty-ledger conservative reading (the
351
+ # enforcer's rule): a candidate whose origin sessions hold NO evidence
352
+ # ids and that carries a reference is dropped too. A skill with no
353
+ # references is not a grounding casualty. -> bool
354
+ def grounded?(skill, sessions, matcher)
355
+ text = [skill["name"], skill["description"], skill["body"]].join(" ")
356
+ refs = matcher.references(text)
357
+ return true if refs.empty?
358
+
359
+ union = sessions.flat_map { |s| s[:evidence] }.uniq
360
+ return false if union.empty?
361
+
362
+ missing = refs.reject { |r| union.include?(r) }
363
+ missing.empty?
364
+ end
365
+
366
+ # The dedup ledger: the same name already in the store's SkillStore
367
+ # (shared or agent scope) or an open (agent, name) tuple.
368
+ def deduped?(skill, agent)
369
+ name = skill["name"].to_s
370
+ store_has = @skill_store && (@skill_store.get(name) || @skill_store.get(name, agent: agent))
371
+ store_has || @harvest_store.open_pending?(agent: agent, name: name)
372
+ end
373
+
374
+ # The miner's schema drops + the filters' — one record; the keys never
375
+ # collide (the filters own rule ids and their own names).
376
+ def merge_rejected(dropped, filters)
377
+ dropped.merge(filters) { |_k, left, right| left + right }
378
+ end
379
+
380
+ def utility_model
381
+ return nil unless @settings_store
382
+
383
+ @settings_store.get["utility_model"]
384
+ end
385
+
386
+ def emit(type, **data)
387
+ @event_stream.emit(Insika::Event.new(
388
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
389
+ ))
390
+ end
391
+ end
392
+ end
393
+ end