insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: takes a CANDIDATE for a completed
8
+ # refinement run, validates it against the agent's write allowlist, and scores it
9
+ # by actually running it — clone the agent, apply the edits to the clone, replay
10
+ # the golden set, compare to the accepted baseline.
11
+ #
12
+ # Synchronous like `run_refinement`, and for the same reason: it is operator-paced
13
+ # work with a human waiting on the answer. It is NOT cheap — the replay is a real
14
+ # conversation per golden case — so it is fired deliberately, never on a timer
15
+ # (the engine has no scheduler and this RFC does not add one).
16
+ #
17
+ # Payload:
18
+ # run_id (required) a run in :completed — the evidence the candidate answers
19
+ # candidate { proposer?, rationale?, edits: [ {file, op, anchor?, before, after,
20
+ # addresses?} ] } — or omit it and pass `propose: true` to have the
21
+ # configured model(s) write one from the run's findings.
22
+ # propose truthy — write the candidate with the agent's proposer PANEL.
23
+ # tolerance Float — overrides the configured judge-score tolerance for this gate
24
+ #
25
+ # -> the Run, now :awaiting_approval (gate passed), :applied (`mode: auto_apply`
26
+ # and the candidate cleared the extra bar) or :rejected (the gate said no).
27
+ #
28
+ # **`propose: true` is required rather than inferred from a missing candidate.**
29
+ # Both a proposal and a gate cost real provider money, and a caller who simply
30
+ # forgot the candidate should get an error, not a bill.
31
+ #
32
+ # Two refusals happen BEFORE anything is cloned, because both mean the operator
33
+ # has not actually enabled this: an agent still in `mode: report` (writing
34
+ # is opt-in and an absent config is report-only), and a candidate whose every edit
35
+ # was dropped (stale, off-allowlist, over budget). Neither is worth a provider bill.
36
+ #
37
+ # makes the proposal a PANEL and the run's spend a BUDGET. The
38
+ # command's shape does not change: N models write N candidates, the gate scores
39
+ # each, and the best survivor is the one proposal a human is shown. A deployment
40
+ # with one proposer and no budget behaves exactly as did.
41
+ class GateRefinement
42
+ WRITE_MODES = %w[propose auto_apply].freeze
43
+
44
+ # An unattended write is held to a SMALLER bar than an approved one: one edit,
45
+ # by default. `auto_apply` exists so a well-understood, well-golden'd agent can
46
+ # fix a typo'd instruction overnight — not so it can rewrite three files while
47
+ # nobody is looking. The operator raises it deliberately.
48
+ DEFAULT_AUTO_APPLY_MAX_EDITS = 1
49
+
50
+ # proposer_factory: ->(refinement_config) { [Refinement::Proposer] | Proposer | nil }.
51
+ # Optional — a deployment with none can still gate a candidate that arrives from
52
+ # the API, which is exactly what shipped before the proposer existed.
53
+ # resolver: the :resolve_refinement handler, used ONLY for `mode: auto_apply`.
54
+ # Reusing it rather than writing files here is what keeps auto-apply honest: it
55
+ # goes through the same staleness re-check, the same versioned write and the
56
+ # same `:refinement_applied` event a human approval does.
57
+ def initialize(profiles:, refinement_store:, agent_file_store:, gate:, event_stream:,
58
+ proposer_factory: nil, resolver: nil)
59
+ @profiles = ProfileSource.coerce(profiles)
60
+ @runs = refinement_store
61
+ @agent_files = agent_file_store
62
+ @gate = gate
63
+ @event_stream = event_stream
64
+ @proposer_factory = proposer_factory
65
+ @resolver = resolver
66
+ end
67
+
68
+ def call(command)
69
+ p = AgentPayload.symbolize(command.payload)
70
+ run_id = AgentPayload.presence(p[:run_id])
71
+ raise Insika::ValidationError, "run_id is required" if run_id.nil?
72
+
73
+ run = @runs.find(run_id) || (raise Insika::NotFoundError, "refinement run not found: #{run_id}")
74
+ profile = @profiles[run.agent_id] ||
75
+ (raise Insika::NotFoundError, "agent '#{run.agent_id}' not configured")
76
+ config = refinement_config(profile)
77
+ require_write_mode!(config, run.agent_id)
78
+ require_completed!(run)
79
+
80
+ result = run_panel(run, config, p)
81
+ report = (result.winner || Refinement::Panel.best_refusal(result.entries)).report
82
+
83
+ gated = @runs.gated(run.id, report: report, panel: result.entries,
84
+ cost: result.budget)
85
+ emit(:refinement_gated, gated, passed: report.passed, reason: report.reason,
86
+ cases: report.cases, passed_cases: report.passed_cases,
87
+ regressions: report.regressions.size,
88
+ candidates: result.entries.size,
89
+ tokens: result.budget.to_h["spent"])
90
+ auto_apply(gated, config, report) || gated
91
+ end
92
+
93
+ private
94
+
95
+ # Propose (a panel, or the caller's own candidate), build, gate, rank. The
96
+ # `gating` status and the `:refinement_proposed` event land BEFORE any scoring,
97
+ # because the scoring is minutes of real replay and a run that says nothing
98
+ # until it finishes looks hung.
99
+ def run_panel(run, config, payload)
100
+ panel = Refinement::Panel.new(
101
+ gate: @gate, proposers: proposers_for(run, config, payload),
102
+ budget: Refinement::Budget.new(tokens: budget_tokens(config))
103
+ )
104
+
105
+ panel.run(agent_id: run.agent_id, run_id: run.id, findings: run.findings,
106
+ files: writable_files(run.agent_id, config),
107
+ allowlist: allowlist!(run.agent_id, config),
108
+ contents: current_files(run.agent_id), limits: config,
109
+ raw: payload[:candidate], tolerance: payload[:tolerance]) do |entries|
110
+ @runs.gating(run.id, candidates: entries)
111
+ emit(:refinement_proposed, run, candidates: entries.size,
112
+ proposers: entries.flat_map(&:proposers).uniq,
113
+ edits: entries.sum { |e| e.candidate.edits.size })
114
+ end
115
+ end
116
+
117
+ def budget_tokens(config)
118
+ budget = config["budget"]
119
+ budget.is_a?(Hash) ? budget["tokens"] : nil
120
+ end
121
+
122
+ def refinement_config(profile)
123
+ raw = profile.respond_to?(:refinement) ? profile.refinement : nil
124
+ raw.is_a?(Hash) ? Coercion.deep_stringify(raw) : {}
125
+ end
126
+
127
+ # Editing an agent's instructions is opt-in, explicitly, per agent — the whole
128
+ # point of is that the reachable surface is small and declared. An absent
129
+ # or `report` config is not "not configured yet", it is a NO.
130
+ def require_write_mode!(config, agent_id)
131
+ mode = Coercion.presence(config["mode"]) || "report"
132
+ return if WRITE_MODES.include?(mode)
133
+
134
+ raise Insika::ValidationError,
135
+ "agent '#{agent_id}' is in refinement mode '#{mode}' — set `refinement.mode` to " \
136
+ "propose (and list the writable files) before gating a candidate"
137
+ end
138
+
139
+ # `RefinementStore#gating` refuses a run that is not :completed — but it is
140
+ # called AFTER the proposal, so the refusal used to arrive with the provider
141
+ # bill already paid. Found live: a run_id pointing at an :awaiting_approval run
142
+ # burned a minute and two model calls to learn the run could not be gated.
143
+ # Same rule as mode and allowlist: everything the engine can refuse for free
144
+ # is refused before a model is asked anything.
145
+ def require_completed!(run)
146
+ return if run.status == :completed
147
+
148
+ raise ArgumentError, "run #{run.id} is #{run.status}, expected completed"
149
+ end
150
+
151
+ # The model(s) write the candidates. Each is shown the run's findings
152
+ # and the CURRENT content of the allowlisted files only — the same allowlist the
153
+ # builder then enforces, so a proposal cannot even name a file it may not touch.
154
+ #
155
+ # A caller who supplied their own candidate needs no proposer at all; that path
156
+ # is what an API client and the phase-C Studio button use.
157
+ def proposers_for(run, config, payload)
158
+ return [] if payload[:candidate]
159
+
160
+ unless Insika::EnvSchema.truthy?(payload[:propose])
161
+ raise Insika::ValidationError,
162
+ "candidate is required (or pass propose: true to have the configured model write one)"
163
+ end
164
+
165
+ panel = Array(@proposer_factory&.call(config))
166
+ if panel.empty?
167
+ raise Insika::ValidationError,
168
+ "no proposer is configured for '#{run.agent_id}' — set `refinement.proposers` " \
169
+ "(or `refinement.proposer`, or a platform utility_model) to a model that can " \
170
+ "write the candidate"
171
+ end
172
+
173
+ panel
174
+ end
175
+
176
+ # The allowlist ∩ what the agent actually has. A configured file the agent never
177
+ # got is not shown and not editable — the builder would drop the edit anyway
178
+ # ("does not exist for this agent"), and paying a model to write one is waste.
179
+ def writable_files(agent_id, config)
180
+ allow = allowlist!(agent_id, config)
181
+ current_files(agent_id).select { |name, _| allow.include?(name) }
182
+ end
183
+
184
+ # Checked before the proposer runs as well as before the gate: an empty
185
+ # allowlist is `mode: propose` with nothing switched on, and it is the one
186
+ # refusal that must land before a provider call, not after it.
187
+ def allowlist!(agent_id, config)
188
+ allow = Array(config["files"]).map(&:to_s)
189
+ if allow.empty?
190
+ raise Insika::ValidationError,
191
+ "agent '#{agent_id}' has an empty refinement.files allowlist — nothing is writable"
192
+ end
193
+
194
+ allow
195
+ end
196
+
197
+ # `mode: auto_apply` — the ONE path where a prompt changes with no
198
+ # human in the loop. Off by default and deliberately narrow: it needs the mode,
199
+ # a gate PASS with zero regressions, and a diff under `auto_apply_max_edits`.
200
+ # Everything else parks at :awaiting_approval, which is the product.
201
+ #
202
+ # A candidate that passed but is too large is NOT rejected — it waits for a
203
+ # person. "Too big to apply unattended" and "wrong" are different verdicts, and
204
+ # collapsing them would throw away a proposal the gate already paid to score.
205
+ #
206
+ # Without a wired resolver this returns nil and the run stays awaiting approval:
207
+ # a deployment that cannot apply must not record that it did.
208
+ # -> the applied Run, or nil when nothing was applied.
209
+ def auto_apply(run, config, report)
210
+ return nil unless Coercion.presence(config["mode"]) == "auto_apply"
211
+ return nil unless run.awaiting_approval? && report.passed && report.regressions.empty?
212
+ return nil if @resolver.nil?
213
+
214
+ max = (config["auto_apply_max_edits"] || DEFAULT_AUTO_APPLY_MAX_EDITS).to_i
215
+ edits = run.edits.size
216
+ if edits > max
217
+ emit(:refinement_auto_apply_skipped, run, edits: edits, max_edits: max)
218
+ return nil
219
+ end
220
+
221
+ @resolver.call(Insika::Command.build(:resolve_refinement,
222
+ { run_id: run.id, decision: "approved",
223
+ operator: "auto_apply",
224
+ note: "auto_apply: gate passed #{report.passed_cases}/" \
225
+ "#{report.cases} with no regression" }))
226
+ end
227
+
228
+ def current_files(agent_id)
229
+ @agent_files.list(agent_id).each_with_object({}) do |name, acc|
230
+ acc[name] = @agent_files.read(agent_id, name).to_s
231
+ end
232
+ end
233
+
234
+ # Counts and ids only, never file content: these events reach the operator
235
+ # stream and the same rule the findings follow applies here.
236
+ def emit(type, run, **data)
237
+ @event_stream.emit(Insika::Event.new(
238
+ type: type,
239
+ data: { agent: run.agent_id, run_id: run.id, status: run.status.to_s, **data },
240
+ meta: { at: Time.now.utc.iso8601 }
241
+ ))
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: LIVE MCP ingestion. Receives the NAME of
8
+ # an MCP instance, delegates to McpToolIngestor (discovers via an injectable client
9
+ # -> builds the manifest -> reuses :import_tools: batch upsert + hot reload)
10
+ # and returns the per-tool report in the shape of :import_tools, plus
11
+ # `instance:`. Idempotent (re-ingesting reconciles). A per-tool failure is isolated
12
+ # in `errors[]` (R4); an absent/disabled/url-less instance raises.
13
+ # -> { instance, version, created: [names], updated: [names], errors: [{tool,error}] }
14
+ class ImportMcpTools
15
+ def initialize(ingestor:, event_stream:)
16
+ @ingestor = ingestor
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, "import_mcp_tools: 'name' (MCP instance) is required")
24
+
25
+ report = @ingestor.ingest(name)
26
+ emit(report)
27
+ report
28
+ end
29
+
30
+ private
31
+
32
+ # Emits only COUNTS + the instance name (never headers/env/secrets): 0 leakage.
33
+ def emit(report)
34
+ @event_stream.emit(Insika::Event.new(
35
+ type: :mcp_tools_imported,
36
+ data: { instance: report[:instance],
37
+ created: report[:created].size, updated: report[:updated].size,
38
+ errors: report[:errors].size },
39
+ meta: { at: Time.now.utc.iso8601 }
40
+ ))
41
+ end
42
+
43
+ def symbolize(payload)
44
+ (payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # BATCH ingestion of data-tools from a manifest.
8
+ # Normalizes each tool (defaults + envelope adapter + endpoint→url + secret/
9
+ # env) via ToolManifest, UPSERTs it into the ToolStore, and RELOADS the overlay +
10
+ # catalog ONCE at the end — takes effect without a restart. Idempotent
11
+ # (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
12
+ # resolvers: the secret NEVER comes in the manifest.
13
+ #
14
+ # ISOLATED PARTIAL FAILURE (R4): a malformed tool (invalid envelope, missing
15
+ # endpoint, unconfigured secret, collision with a code tool, invalid url)
16
+ # does NOT bring down the batch — it becomes an entry in `errors[]`. Only a STRUCTURAL error in the
17
+ # manifest (defaults/tools of the wrong type) raises (transport -> 422).
18
+ # Per-tool report in the shape of the pack importer.
19
+ # -> { version, created: [names], updated: [names], errors: [{tool,error}] }
20
+ class ImportTools
21
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:, secrets: ENV, env: ENV)
22
+ @tool_store = tool_store
23
+ @registry = registry
24
+ @tool_catalog = tool_catalog
25
+ @event_stream = event_stream
26
+ @secrets = secrets
27
+ @env = env
28
+ end
29
+
30
+ def call(command)
31
+ manifest = Insika::ToolManifest.from_h(command.payload) # structural -> 422
32
+ report = { version: manifest.version, created: [], updated: [], errors: [] }
33
+
34
+ manifest.tools.each_with_index do |raw, i|
35
+ import_one(manifest, raw, i, report)
36
+ end
37
+
38
+ reload_hot unless report[:created].empty? && report[:updated].empty?
39
+ emit(report)
40
+ report
41
+ end
42
+
43
+ private
44
+
45
+ def import_one(manifest, raw, index, report)
46
+ defn = manifest.definition_for(raw, secrets: @secrets, env: @env)
47
+ name = defn["name"].to_s
48
+ raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
49
+
50
+ existed = !@tool_store.get(name).nil?
51
+ @tool_store.write(defn)
52
+ (existed ? report[:updated] : report[:created]) << name
53
+ rescue Insika::Error => e
54
+ report[:errors] << { tool: tool_label(raw, index), error: e.message }
55
+ end
56
+
57
+ def reload_hot
58
+ @registry.reload
59
+ @tool_catalog.reload
60
+ end
61
+
62
+ # Tool name for the error report; falls back to the index when the envelope
63
+ # didn't even carry a name (keeps the error entry from being anonymous).
64
+ def tool_label(raw, index)
65
+ h = raw.is_a?(Hash) ? raw : {}
66
+ name = h["name"] || h[:name] || h.dig("function", "name") || h.dig(:function, :name)
67
+ Insika::Coercion.presence(name) || "#<tool ##{index}>"
68
+ end
69
+
70
+ # Emits only COUNTS + names (never headers/secrets): 0 leakage.
71
+ def emit(report)
72
+ @event_stream.emit(Insika::Event.new(
73
+ type: :tools_imported,
74
+ data: { created: report[:created].size, updated: report[:updated].size,
75
+ errors: report[:errors].size },
76
+ meta: { at: Time.now.utc.iso8601 }
77
+ ))
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # WS1: issues a PER-TENANT token (multi_tenant mode). Operator-only BY
6
+ # CONSTRUCTION: the edge refuses a tenant principal on POST /v1/commands
7
+ # (403), and an internal command stamped with a tenant is refused here —
8
+ # a tenant can never mint credentials. The plaintext token is the response
9
+ # and exists nowhere else; the store keeps only its hash. -> Issue.to_h.
10
+ class IssueTenantToken
11
+ def initialize(token_store:, event_stream:)
12
+ @token_store = token_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ raise Insika::ValidationError, "token commands are operator-only" if command.meta[:tenant]
18
+
19
+ tenant_id = Insika::Coercion.presence(
20
+ command.payload[:tenant_id] || command.payload["tenant_id"]
21
+ )
22
+ raise Insika::ValidationError, "tenant_id is required" if tenant_id.nil?
23
+
24
+ label = command.payload[:label] || command.payload["label"] || "default"
25
+ issue = @token_store.issue(tenant_id: tenant_id, label: label)
26
+ emit(issue.id, tenant_id)
27
+ { token: issue.token, id: issue.id, tenant_id: tenant_id, label: label.to_s }
28
+ end
29
+
30
+ private
31
+
32
+ def emit(token_id, tenant_id)
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :tenant_token_issued,
35
+ data: { token_id: token_id, tenant_id: tenant_id },
36
+ meta: { at: Time.now.utc.iso8601 }
37
+ ))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: appends a free-form note
8
+ # (append-only) to the agent's memory (MemoryStore, `notes` layer). Scoped
9
+ # by `tenant`. Synchronous; does not create a Task. -> Note.
10
+ class MemoryAddNote
11
+ def initialize(memory_store:, event_stream:)
12
+ @memory_store = memory_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ text = AgentPayload.presence(p[:text])
19
+ raise Insika::ValidationError, "text is required" if text.nil?
20
+
21
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
22
+ note = @memory_store.add_note(tenant: tenant, text: text)
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :memory_note_added,
25
+ data: { tenant: tenant, note_id: note.id },
26
+ meta: { at: Time.now.utc.iso8601 }
27
+ ))
28
+ note
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: forgets (removes) a fact from the
8
+ # agent's memory. Idempotent: forgetting something that doesn't exist is not an error
9
+ # (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
10
+ class MemoryForgetFact
11
+ def initialize(memory_store:, event_stream:)
12
+ @memory_store = memory_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ key = AgentPayload.presence(p[:key])
19
+ raise Insika::ValidationError, "key is required" if key.nil?
20
+
21
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
22
+ existed = @memory_store.forget_fact(tenant: tenant, key: key)
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :memory_fact_forgotten,
25
+ data: { tenant: tenant, key: key, existed: existed },
26
+ meta: { at: Time.now.utc.iso8601 }
27
+ ))
28
+ { existed: existed }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes a stable fact to the agent's
8
+ # memory (MemoryStore, `profile` layer). Until now memory was only written
9
+ # from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
10
+ # Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
11
+ # Synchronous; does not create a Task. -> Fact.
12
+ class MemoryPutFact
13
+ def initialize(memory_store:, event_stream:)
14
+ @memory_store = memory_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ key = AgentPayload.presence(p[:key])
21
+ raise Insika::ValidationError, "key is required" if key.nil?
22
+ raise Insika::ValidationError, "value is required" if p[:value].nil?
23
+
24
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
25
+ fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value])
26
+ @event_stream.emit(Insika::Event.new(
27
+ type: :memory_fact_put,
28
+ data: { tenant: tenant, key: key },
29
+ meta: { at: Time.now.utc.iso8601 }
30
+ ))
31
+ fact
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Control command: posts `:pause` to the Task's in-process
6
+ # mailbox and responds synchronously. Payload `{ task_id: String }` -> returns Task.
7
+ #
8
+ # Cooperative: the one that transitions `running -> paused` and emits
9
+ # `:task_paused` is the task's fiber when it drains at the next boundary — NEVER this
10
+ # handler. Pausing a task with no live fiber (terminal/orphaned) is an idempotent no-op.
11
+ class PauseTask
12
+ def initialize(task_store:, executor:)
13
+ @task_store = task_store
14
+ @executor = executor
15
+ end
16
+
17
+ def call(command)
18
+ task_id = command.payload[:task_id] || command.payload["task_id"]
19
+ raise Insika::ValidationError, "task_id is required" if task_id.to_s.empty?
20
+
21
+ task = @task_store.find(task_id)
22
+ raise Insika::NotFoundError, "task '#{task_id}' not found" unless task
23
+
24
+ @executor.pause(task_id) # no-op if there is no live fiber in this process
25
+ @task_store.find(task_id) # current state after the post
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: the human's answer to a gated proposal.
8
+ #
9
+ # approve -> each edit is written through `AgentFileStore#write`, which pushes
10
+ # the previous content into `history`. Rollback is therefore already
11
+ # built: `restore_agent_file` with index 0, the same button the file
12
+ # editor has had since the Studio existed.
13
+ # reject -> recorded on the run with the operator's note. The finding must
14
+ # re-surface with new evidence before anything is proposed again;
15
+ # nothing retries on its own.
16
+ #
17
+ # Payload `{ run_id:, decision: "approved"|"rejected", operator?, note? }`.
18
+ # -> the resolved Run.
19
+ #
20
+ # **The candidate is re-validated against the CURRENT files before anything is
21
+ # written.** The gate scored a snapshot; an operator approves minutes or days
22
+ # later, and in between a human may have edited the same file in the Studio.
23
+ # Applying an edit whose `before` no longer matches would silently overwrite that
24
+ # person's work — the exact failure the anchored format exists to prevent, thrown
25
+ # away at the last step. A stale approval is refused and the operator re-gates.
26
+ class ResolveRefinement
27
+ def initialize(profiles:, refinement_store:, agent_file_store:, event_stream:)
28
+ @profiles = ProfileSource.coerce(profiles)
29
+ @runs = refinement_store
30
+ @agent_files = agent_file_store
31
+ @event_stream = event_stream
32
+ end
33
+
34
+ def call(command)
35
+ p = AgentPayload.symbolize(command.payload)
36
+ run_id = AgentPayload.presence(p[:run_id])
37
+ decision = AgentPayload.presence(p[:decision]).to_s
38
+ raise Insika::ValidationError, "run_id is required" if run_id.nil?
39
+
40
+ run = @runs.find(run_id) || (raise Insika::NotFoundError, "refinement run not found: #{run_id}")
41
+ operator = p[:operator] || command.meta[:operator]
42
+
43
+ case decision
44
+ when "rejected"
45
+ resolved = @runs.resolve(run.id, decision: :rejected, operator: operator, note: p[:note])
46
+ emit(:refinement_rejected, resolved, files: [])
47
+ resolved
48
+ when "approved"
49
+ apply(run, operator: operator, note: p[:note])
50
+ else
51
+ raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def apply(run, operator:, note:)
58
+ unless run.awaiting_approval?
59
+ raise ArgumentError, "run #{run.id} is #{run.status}, expected awaiting_approval"
60
+ end
61
+
62
+ config = refinement_config(run.agent_id)
63
+ contents = current_files(run.agent_id)
64
+ candidate = rebuild(run, config, contents)
65
+
66
+ written = candidate.apply(contents)
67
+ written.each { |file, body| @agent_files.write(run.agent_id, file, body) }
68
+
69
+ # Recorded AFTER the writes land: a crash in between leaves the run awaiting
70
+ # approval and the operator approves again. Re-approving re-validates against
71
+ # the (now edited) files and refuses as stale, which is a confusing message but
72
+ # a safe outcome — recording `applied` before writing would be a lie the
73
+ # history could not correct.
74
+ resolved = @runs.resolve(run.id, decision: :applied, operator: operator, note: note)
75
+ emit(:refinement_applied, resolved, files: written.keys, edits: candidate.edits.size)
76
+ resolved
77
+ end
78
+
79
+ # Re-runs the SAME validator the gate used, against the files as they are now.
80
+ # Anything that drifted comes back as a drop, and any drop at all refuses the
81
+ # apply: a partial application would land some edits and silently skip others,
82
+ # leaving a prompt in a state no one reviewed and the gate never scored.
83
+ def rebuild(run, config, contents)
84
+ allowlist = Array(config["files"]).map(&:to_s)
85
+ raw = { "proposer" => (run.candidate || {})["proposer"],
86
+ "rationale" => (run.candidate || {})["rationale"],
87
+ "edits" => run.edits }
88
+ candidate = Refinement::CandidateBuilder.build(raw, allowlist: allowlist,
89
+ contents: contents, limits: config)
90
+
91
+ if candidate.dropped.any? || candidate.edits.size != run.edits.size
92
+ reasons = candidate.dropped.map { |d| "#{d.file}: #{d.reason}" }
93
+ raise Insika::ValidationError,
94
+ "the files changed since this proposal was gated, so it no longer applies " \
95
+ "(#{reasons.join('; ')}). Re-run the gate against the current files."
96
+ end
97
+
98
+ candidate
99
+ end
100
+
101
+ def refinement_config(agent_id)
102
+ profile = @profiles[agent_id] ||
103
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
104
+ raw = profile.respond_to?(:refinement) ? profile.refinement : nil
105
+ raw.is_a?(Hash) ? Coercion.deep_stringify(raw) : {}
106
+ end
107
+
108
+ def current_files(agent_id)
109
+ @agent_files.list(agent_id).each_with_object({}) do |name, acc|
110
+ acc[name] = @agent_files.read(agent_id, name).to_s
111
+ end
112
+ end
113
+
114
+ # File NAMES are fine on the stream (the operator needs to know what changed);
115
+ # their content is not, and never appears here.
116
+ def emit(type, run, **data)
117
+ @event_stream.emit(Insika::Event.new(
118
+ type: type,
119
+ data: { agent: run.agent_id, run_id: run.id, status: run.status.to_s,
120
+ by: run.decision && run.decision["by"], **data },
121
+ meta: { at: Time.now.utc.iso8601 }
122
+ ))
123
+ end
124
+ end
125
+ end
126
+ end