insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -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 +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -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 +362 -0
  22. data/docs/SKILLS.md +98 -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 +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates an agent (AgentProfile) at
8
+ # RUNTIME and persists it in the ProfileSource (ConfigStore). This is the "everyone creates
9
+ # their own BIA". Synchronous; does not create a Task. -> AgentProfile (round-tripped from the store).
10
+ class CreateAgent
11
+ def initialize(profile_source:, event_stream:)
12
+ @profile_source = profile_source
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ attrs = AgentPayload.attrs(command.payload)
18
+ id = AgentPayload.presence(attrs[:id])
19
+ raise Insika::ValidationError, "id is required" if id.nil?
20
+ # model is OPTIONAL as of v2 (§10): omitted -> resolves the platform
21
+ # default_model (Settings) at turn start. An agent with neither its own
22
+ # model nor a platform default fails clearly at the first turn.
23
+ raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
24
+
25
+ profile = Insika::AgentProfile.build(**attrs)
26
+ # RFC-0010 §4.4: definition-time cycle + depth check. Only a profile that
27
+ # introduces edges (non-empty subagents) can create a violation — a
28
+ # childless agent is always a safe leaf. Raises SubagentError (a
29
+ # ValidationError) BEFORE persisting, so a bad graph never lands.
30
+ validate_subagent_graph!(profile)
31
+ @profile_source.put(profile)
32
+ emit(:agent_created, id)
33
+ @profile_source.fetch(id) # returns the persisted profile (symbols already normalized)
34
+ end
35
+
36
+ private
37
+
38
+ # Validates the delegation graph with `profile` added to the current set.
39
+ def validate_subagent_graph!(profile)
40
+ return if Array(profile.subagents).empty?
41
+
42
+ others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
43
+ Insika::SubagentGraph.validate!(others + [profile])
44
+ end
45
+
46
+ def emit(type, id)
47
+ @event_stream.emit(Insika::Event.new(
48
+ type: type, data: { agent_id: id },
49
+ meta: { at: Time.now.utc.iso8601 }
50
+ ))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates a session and responds synchronously —
8
+ # does not create a Task. Payload `{ vars: {} }` -> returns Session. `id` optional:
9
+ # when present, the session is born with that id (correlation by external key —
10
+ # e.g.: chat.id in the /v1/responses adapter; contextId in A2A). Absent -> uuid.
11
+ class CreateSession
12
+ # event_stream: any object with #emit(event) (tests use a spy).
13
+ def initialize(session_store:, event_stream:)
14
+ @session_store = session_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ # Accepts a symbol key (internal dispatch) and a string key (transport JSON)
20
+ # — normalizes at the handler boundary.
21
+ vars = command.payload[:vars] || command.payload["vars"] || {}
22
+ raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
23
+
24
+ # Per-chat model pin (v2, §10): `model`/`provider` on the payload become a
25
+ # reserved, collision-safe slot in vars (never rendered in the prompt — the
26
+ # Request provider skips "__"-prefixed vars). The ModelResolver reads it as
27
+ # the highest-precedence layer (Chat > Agent > platform default).
28
+ vars = apply_model_pin(vars.dup, command.payload)
29
+
30
+ id = command.payload[:id] || command.payload["id"]
31
+ session = id ? @session_store.create(id: id, vars: vars) : @session_store.create(vars: vars)
32
+ # :session_created is from the closed catalog (origin: CreateSession handler).
33
+ # meta without task_id/seq (control has no Task); Event#to_h does meta.compact.
34
+ @event_stream.emit(Insika::Event.new(
35
+ type: :session_created,
36
+ data: { session_id: session.id },
37
+ meta: { session_id: session.id, at: Time.now.utc.iso8601 }
38
+ ))
39
+ session
40
+ end
41
+
42
+ private
43
+
44
+ # Reads `model`/`provider`/`thinking` (string|symbol keys) and, when any is
45
+ # present, writes the reserved vars slot. All must be strings; `thinking` is
46
+ # the per-chat reasoning override (off/on/low/medium/high) and may be set
47
+ # WITHOUT a model pin. Absent -> vars unchanged.
48
+ def apply_model_pin(vars, payload)
49
+ model = payload[:model] || payload["model"]
50
+ provider = payload[:provider] || payload["provider"]
51
+ thinking = payload[:thinking] || payload["thinking"]
52
+ return vars if model.nil? && provider.nil? && thinking.nil?
53
+
54
+ raise Insika::ValidationError, "model must be a String" unless model.nil? || model.is_a?(String)
55
+ raise Insika::ValidationError, "provider must be a String" unless provider.nil? || provider.is_a?(String)
56
+ raise Insika::ValidationError, "thinking must be a String" unless thinking.nil? || thinking.is_a?(String)
57
+
58
+ pin = {}
59
+ pin["model"] = model if model
60
+ pin["provider"] = provider if provider
61
+ pin["thinking"] = thinking if thinking
62
+ vars[Insika::ModelResolver::SESSION_SLOT] = pin
63
+ vars
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes an agent. -> the removed AgentProfile
8
+ # (so the transport can confirm what disappeared). Turns ALREADY in progress
9
+ # that captured the profile keep going until they finish (the ProfileSource only affects
10
+ # new dispatches).
11
+ class DeleteAgent
12
+ def initialize(profile_source:, event_stream:)
13
+ @profile_source = profile_source
14
+ @event_stream = event_stream
15
+ end
16
+
17
+ def call(command)
18
+ id = AgentPayload.presence(AgentPayload.symbolize(command.payload)[:id])
19
+ raise Insika::ValidationError, "id is required" if id.nil?
20
+
21
+ removed = @profile_source.fetch(id) ||
22
+ (raise Insika::NotFoundError, "agent '#{id}' not found")
23
+
24
+ @profile_source.delete(id)
25
+ @event_stream.emit(Insika::Event.new(
26
+ type: :agent_deleted, data: { agent_id: id },
27
+ meta: { at: Time.now.utc.iso8601 }
28
+ ))
29
+ removed
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes a prompt file from an agent's
8
+ # workspace. Nonexistent -> NotFoundError. -> { agent_id, file }.
9
+ class DeleteAgentFile
10
+ def initialize(profile_source:, agent_file_store:, event_stream:)
11
+ @profile_source = profile_source
12
+ @agent_files = agent_file_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ agent_id = AgentPayload.presence(p[:agent_id])
19
+ file = AgentPayload.presence(p[:file])
20
+ raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
21
+ raise Insika::ValidationError, "file is required" if file.nil?
22
+
23
+ unless @agent_files.delete(agent_id, file)
24
+ raise Insika::NotFoundError, "file '#{file}' not found for agent '#{agent_id}'"
25
+ end
26
+
27
+ unregister_prompt_file(agent_id, file)
28
+ @event_stream.emit(Insika::Event.new(
29
+ type: :agent_file_deleted, data: { agent_id: agent_id, file: file },
30
+ meta: { at: Time.now.utc.iso8601 }
31
+ ))
32
+ { agent_id: agent_id, file: file }
33
+ end
34
+
35
+ private
36
+
37
+ # Removing the file also drops it from prompt_files (the inverse operation of
38
+ # WriteAgentFile) — the Command's job, not the dispatcher's.
39
+ def unregister_prompt_file(agent_id, file)
40
+ profile = @profile_source.fetch(agent_id)
41
+ return unless profile
42
+
43
+ files = Array(profile.prompt_files).map(&:to_s)
44
+ return unless files.include?(file)
45
+
46
+ @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files - [file])))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Removes a data tool from the ToolStore and reloads overlay + catalog.
8
+ # 404 if it did not exist. -> { name }.
9
+ class DeleteDataTool
10
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
11
+ @tool_store = tool_store
12
+ @registry = registry
13
+ @tool_catalog = tool_catalog
14
+ @event_stream = event_stream
15
+ end
16
+
17
+ def call(command)
18
+ p = AgentPayload.symbolize(command.payload)
19
+ name = AgentPayload.presence(p[:name])
20
+ raise Insika::ValidationError, "name is required" if name.nil?
21
+ raise Insika::NotFoundError, "tool '#{name}' not found" unless @tool_store.delete(name)
22
+
23
+ @registry.reload
24
+ @tool_catalog.reload
25
+ @event_stream.emit(Insika::Event.new(
26
+ type: :data_tool_deleted, data: { name: name },
27
+ meta: { at: Time.now.utc.iso8601 }
28
+ ))
29
+ { name: name }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes an LLM provider from the
8
+ # LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
9
+ # config in RubyLLM at runtime (§9.5): when the provider existed, calls
10
+ # `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
11
+ # that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
12
+ # undo). -> { existed: bool }.
13
+ class DeleteLLMProvider
14
+ def initialize(provider_store:, configurator:, event_stream:)
15
+ @provider_store = provider_store
16
+ @configurator = configurator
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = AgentPayload.symbolize(command.payload)
22
+ api = AgentPayload.presence(p[:api])
23
+ raise Insika::ValidationError, "api is required" if api.nil?
24
+
25
+ existed = @provider_store.delete(api)
26
+ @configurator.unapply(api) if existed
27
+ @event_stream.emit(Insika::Event.new(
28
+ type: :llm_provider_deleted,
29
+ data: { api: api, existed: existed },
30
+ meta: { at: Time.now.utc.iso8601 }
31
+ ))
32
+ { existed: existed }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes an MCP instance from the
8
+ # McpStore. Idempotent (`existed: false` if there was none). -> { existed: bool }.
9
+ class DeleteMcp
10
+ def initialize(mcp_store:, event_stream:)
11
+ @mcp_store = mcp_store
12
+ @event_stream = event_stream
13
+ end
14
+
15
+ def call(command)
16
+ p = AgentPayload.symbolize(command.payload)
17
+ name = AgentPayload.presence(p[:name])
18
+ raise Insika::ValidationError, "name is required" if name.nil?
19
+
20
+ existed = @mcp_store.delete(name)
21
+ @event_stream.emit(Insika::Event.new(
22
+ type: :mcp_deleted,
23
+ data: { name: name, existed: existed },
24
+ meta: { at: Time.now.utc.iso8601 }
25
+ ))
26
+ { existed: existed }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: removes a global system file
8
+ # from the SystemFileStore. Idempotent (`existed: false`). -> { existed: bool }.
9
+ class DeleteSystemFile
10
+ def initialize(system_file_store:, event_stream:)
11
+ @system_files = system_file_store
12
+ @event_stream = event_stream
13
+ end
14
+
15
+ def call(command)
16
+ p = AgentPayload.symbolize(command.payload)
17
+ file = AgentPayload.presence(p[:file])
18
+ raise Insika::ValidationError, "file is required" if file.nil?
19
+
20
+ existed = @system_files.delete(file)
21
+ @event_stream.emit(Insika::Event.new(
22
+ type: :system_file_deleted, data: { file: file, existed: existed },
23
+ meta: { at: Time.now.utc.iso8601 }
24
+ ))
25
+ { existed: existed }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (RFC-0013 phase C): 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 (§3.5).
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
+ # (D8: 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 (§3.4).
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` (§3.8 — 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
+ # Phase D (§3.9) 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 phase C 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 phase C 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 §3.1 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 (§3.4/§3.9). 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` (§3.6, D2) — 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 (§5).
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 (Phase 7, Step E). 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