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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: enables/disables a skill on N
8
+ # agents at once, adjusting each one's `profile.skills` allowlist — and, with
9
+ # `eager_ids`, which of them keep the body in the prompt on EVERY turn
10
+ # (`profile.skills_eager`). Takes effect on the next dispatch (hot via
11
+ # ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
12
+ #
13
+ # Both are per-agent decisions about the same skill, which is why they are one
14
+ # command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
15
+ # the screen that says "which agents can load this" is the screen that says "and
16
+ # which of them always have it".
17
+ #
18
+ # Allowlist semantics (AgentProfile): nil = ALL, [] = none,
19
+ # [names] = subset. Important and deliberate consequence:
20
+ # - enabling on an agent with skills=nil: no-op (already has all).
21
+ # - DISABLING on an agent with skills=nil: NOT done here — removing one from
22
+ # "all" would require enumerating the catalog and materializing an explicit
23
+ # allowlist (destructive/surprising). These agents are left intact and
24
+ # go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
25
+ # first.
26
+ #
27
+ # `skills_eager` is NOT allowlist semantics (nil = NONE, see
28
+ # SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
29
+ # there is an empty set that can simply be added to, and only the BLANKET `true`
30
+ # has the same "cannot remove one name from all" problem (-> skipped_eager_all).
31
+ class SetSkillAgents
32
+ def initialize(profile_source:, event_stream:)
33
+ @profile_source = profile_source
34
+ @event_stream = event_stream
35
+ end
36
+
37
+ def call(command)
38
+ p = AgentPayload.symbolize(command.payload)
39
+ name = AgentPayload.presence(p[:name])
40
+ raise Insika::ValidationError, "name is required" if name.nil?
41
+ raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
42
+ raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
43
+
44
+ wanted = Array(p[:agent_ids]).map(&:to_s)
45
+ # eager_ids ABSENT means "this form does not manage eagerness" — leave every
46
+ # profile's setting alone. An empty ARRAY means "none of them", which is a real
47
+ # instruction and must be applied. `nil` and `[]` are not the same request.
48
+ eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
49
+ result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
50
+
51
+ @profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
52
+
53
+ @event_stream.emit(Insika::Event.new(
54
+ type: :skill_agents_set,
55
+ data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
56
+ skipped_all: result[:skipped_all] }.compact,
57
+ meta: { at: Time.now.utc.iso8601 }
58
+ ))
59
+ { name: name }.merge(result)
60
+ end
61
+
62
+ private
63
+
64
+ def apply(profile, name, wanted, eager_wanted, result)
65
+ want = wanted.include?(profile.id)
66
+ new_skills = next_skills(profile.skills, name, want)
67
+ new_eager = next_eager(profile, name, eager_wanted, result)
68
+
69
+ if new_skills == :skip
70
+ result[:skipped_all] << profile.id
71
+ new_skills = profile.skills
72
+ end
73
+ result[:enabled_for] << profile.id if want
74
+ result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
75
+ return if new_skills == profile.skills && new_eager == profile.skills_eager
76
+
77
+ @profile_source.put(Insika::AgentProfile.build(
78
+ **profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
79
+ ))
80
+ end
81
+
82
+ # -> new allowlist | :skip (agent with nil that requested disable).
83
+ def next_skills(current, name, want)
84
+ if want
85
+ current.nil? ? nil : (Array(current).map(&:to_s) | [name])
86
+ elsif current.nil?
87
+ :skip
88
+ else
89
+ Array(current).map(&:to_s) - [name]
90
+ end
91
+ end
92
+
93
+ # -> new skills_eager. nil eager_wanted = not managed by this call.
94
+ def next_eager(profile, name, eager_wanted, result)
95
+ current = profile.skills_eager
96
+ return current if eager_wanted.nil?
97
+
98
+ want = eager_wanted.include?(profile.id)
99
+ # Blanket `true` already includes every skill; removing ONE name from it would
100
+ # mean materializing the whole catalog as a list, the same destructive surprise
101
+ # `skipped_all` exists for.
102
+ if Coercion.truthy?(current)
103
+ result[:skipped_eager_all] << profile.id unless want
104
+ return current
105
+ end
106
+
107
+ list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
108
+ want ? (list | [name]) : (list - [name])
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Turn command: fires a workflow. Reuses the canonical
6
+ # pipeline — only stage 6 varies (Executor). Validates everything
7
+ # synchronously and responds `{task_id:}` immediately. The workflow allowlist is NOT
8
+ # checked here: it is stage-3 enforcement via WorkflowAllowlist
9
+ # -> PolicyDenied -> task :failed.
10
+ class TriggerWorkflow
11
+ ALLOWED_KEYS = %i[workflow agent input session_id].freeze
12
+
13
+ def initialize(profiles:, session_store:, task_store:, executor:, workflow_registry:)
14
+ @profiles = ProfileSource.coerce(profiles)
15
+ @session_store = session_store
16
+ @task_store = task_store
17
+ @executor = executor
18
+ @workflow_registry = workflow_registry
19
+ end
20
+
21
+ def call(command)
22
+ p = normalize(command.payload)
23
+ reject_unknown_keys!(command.payload) # strict validation
24
+
25
+ workflow = p[:workflow].to_s
26
+ raise Insika::ValidationError, "workflow is required" if workflow.empty?
27
+
28
+ agent = p[:agent].to_s
29
+ raise Insika::ValidationError, "agent is required" if agent.empty?
30
+
31
+ profile = @profiles[agent] ||
32
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
33
+
34
+ input = p[:input] || {}
35
+ raise Insika::ValidationError, "input must be a Hash" unless input.is_a?(Hash)
36
+
37
+ if p[:session_id]
38
+ @session_store.find(p[:session_id]) ||
39
+ (raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
40
+ end
41
+
42
+ # existence is validatable without executing — names, NEVER resolve (don't instantiate
43
+ # outside the fiber).
44
+ unless @workflow_registry.names.include?(workflow)
45
+ raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
46
+ end
47
+
48
+ # I/O by schema: the INPUT is validated SYNCHRONOUSLY —
49
+ # a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
50
+ # with NO run created. `definition` reads the schema metadata without
51
+ # resolving the factory (stays out of the fiber). No-op without an input_schema.
52
+ @workflow_registry.definition(workflow).validate_input!(input)
53
+
54
+ task = @task_store.create(command: command.to_h, session_id: p[:session_id])
55
+ @executor.spawn_in_session(task, profile: profile)
56
+ # runId = the run's durable record, which IS the Task (checkpointed +
57
+ # recoverable). Exposed under both names so the workflow consumer has a
58
+ # stable `run_id` while every task surface (GET /v1/tasks/:id, the event
59
+ # stream) keeps working unchanged.
60
+ { task_id: task.id, run_id: task.id }
61
+ end
62
+
63
+ private
64
+
65
+ def normalize(payload)
66
+ {
67
+ workflow: payload[:workflow] || payload["workflow"],
68
+ agent: payload[:agent] || payload["agent"],
69
+ input: payload[:input] || payload["input"],
70
+ session_id: payload[:session_id] || payload["session_id"]
71
+ }
72
+ end
73
+
74
+ def reject_unknown_keys!(payload)
75
+ extra = payload.keys.map(&:to_sym) - ALLOWED_KEYS
76
+ raise Insika::ValidationError, "unknown keys in payload: #{extra.join(', ')}" unless extra.empty?
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: edits an existing agent. Merges the
8
+ # patch over the current profile (only the sent fields change) + rebuild + put.
9
+ # Takes effect on the NEXT dispatch (the ProfileSource reads fresh). -> AgentProfile.
10
+ class UpdateAgent
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
+ patch = AgentPayload.attrs(command.payload)
18
+ id = AgentPayload.presence(patch[:id])
19
+ raise Insika::ValidationError, "id is required" if id.nil?
20
+
21
+ existing = @profile_source.fetch(id) ||
22
+ (raise Insika::NotFoundError, "agent '#{id}' not found")
23
+
24
+ # to_h of the current profile brings the correct symbols; patch overwrites only
25
+ # what was sent. id never changes (rename = create+delete, out of scope).
26
+ merged = existing.to_h.merge(patch).merge(id: id)
27
+ profile = Insika::AgentProfile.build(**merged)
28
+ # definition-time cycle + depth check (an update may ADD
29
+ # subagents to an existing agent). Raises SubagentError before persisting.
30
+ validate_subagent_graph!(profile)
31
+ @profile_source.put(profile)
32
+ @event_stream.emit(Insika::Event.new(
33
+ type: :agent_updated, data: { agent_id: id },
34
+ meta: { at: Time.now.utc.iso8601 }
35
+ ))
36
+ @profile_source.fetch(id)
37
+ end
38
+
39
+ private
40
+
41
+ def validate_subagent_graph!(profile)
42
+ return if Array(profile.subagents).empty?
43
+
44
+ others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
45
+ Insika::SubagentGraph.validate!(others + [profile])
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: merges a patch into the
8
+ # general settings (timeouts/streaming/compaction) and persists it in the SettingsStore.
9
+ # Only what came in the patch changes; the rest (and the defaults) is preserved. Takes effect on the
10
+ # next turn that reads settings. -> Hash (resulting settings).
11
+ class UpdateSettings
12
+ def initialize(settings_store:, event_stream:)
13
+ @settings_store = settings_store
14
+ @event_stream = event_stream
15
+ end
16
+
17
+ def call(command)
18
+ p = AgentPayload.symbolize(command.payload)
19
+ patch = p[:patch]
20
+ raise Insika::ValidationError, "patch must be an object" unless patch.nil? || patch.is_a?(Hash)
21
+ raise Insika::ValidationError, "empty patch" if patch.nil? || patch.empty?
22
+
23
+ settings = @settings_store.update(patch)
24
+ @event_stream.emit(Insika::Event.new(
25
+ type: :settings_updated,
26
+ data: { keys: patch.keys.map(&:to_s) },
27
+ meta: { at: Time.now.utc.iso8601 }
28
+ ))
29
+ settings
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates/edits an LLM provider
8
+ # (api/base_url/auth_header/api_key/models) in the LLMProviderStore and RECONFIGURES
9
+ # RubyLLM at runtime (LLMConfigurator) — swaps key/base without a restart.
10
+ # The `api_key` is sentinel-aware (__OCULTO__ preserves; "" clears; a new string
11
+ # replaces). Returns the MASKED record (the key never comes back in plaintext).
12
+ class UpsertLLMProvider
13
+ def initialize(provider_store:, configurator:, event_stream:)
14
+ @provider_store = provider_store
15
+ @configurator = configurator
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ p = AgentPayload.symbolize(command.payload)
21
+ masked = @provider_store.upsert(p) # validates `api`; returns masked
22
+
23
+ # Reconfigures only THIS provider (with the real key from the store, already reconciled).
24
+ result = @configurator.apply([@provider_store.get_raw(masked["api"])])
25
+ @event_stream.emit(Insika::Event.new(
26
+ type: :llm_provider_upserted,
27
+ data: { api: masked["api"], applied: result[:applied], skipped: result[:skipped] },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ masked
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: creates/edits an MCP instance
8
+ # (transport/command/url/enabled + `env` credentials) in the McpStore. The
9
+ # credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
10
+ # a new string replaces). Returns the MASKED record (env never comes back in
11
+ # plaintext). Durable config CRUD — running an MCP client against the
12
+ # instance is later runtime.
13
+ class UpsertMcp
14
+ def initialize(mcp_store:, event_stream:)
15
+ @mcp_store = mcp_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ p = AgentPayload.symbolize(command.payload)
21
+ masked = @mcp_store.upsert(p) # validates `name`; returns masked
22
+
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :mcp_upserted,
25
+ data: { name: masked["name"], enabled: masked["enabled"] },
26
+ meta: { at: Time.now.utc.iso8601 }
27
+ ))
28
+ masked
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes an agent's prompt file to the
8
+ # workspace (AgentFileStore). This is what gives each BIA its own identity — the
9
+ # Prompt provider reads these files via `profile.prompt_files`. Takes effect
10
+ # on the next dispatch (hot). -> { agent_id, file, updated_at }.
11
+ class WriteAgentFile
12
+ def initialize(profile_source:, agent_file_store:, event_stream:)
13
+ @profile_source = profile_source
14
+ @agent_files = agent_file_store
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ agent_id = AgentPayload.presence(p[:agent_id])
21
+ file = AgentPayload.presence(p[:file])
22
+ raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
23
+ raise Insika::ValidationError, "file is required" if file.nil?
24
+ profile = @profile_source.fetch(agent_id) ||
25
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not found")
26
+
27
+ # `.to_s` used to be unconditional here, which is how a JSON body carrying an
28
+ # OBJECT for `content` became a prompt file holding Ruby's #inspect of it. The
29
+ # store refuses that now; keep the coercion for scalars (a form always sends a
30
+ # String, and nil means "clear the file").
31
+ content = Insika::AgentFileStore.text!(p[:content], agent_id, file).to_s
32
+ entry = @agent_files.write(agent_id, file, content, create_only: !!p[:create_only])
33
+ # Writing a prompt registers it in prompt_files — otherwise the Prompt provider
34
+ # would never load it. It's the Command's job, not the dispatcher's.
35
+ register_prompt_file(profile, file)
36
+ emit(:agent_file_written, agent_id, file)
37
+ { agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
38
+ end
39
+
40
+ private
41
+
42
+ def register_prompt_file(profile, file)
43
+ files = Array(profile.prompt_files).map(&:to_s)
44
+ return if files.include?(file)
45
+
46
+ @profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files + [file])))
47
+ end
48
+
49
+ def emit(type, agent_id, file)
50
+ @event_stream.emit(Insika::Event.new(
51
+ type: type, data: { agent_id: agent_id, file: file },
52
+ meta: { at: Time.now.utc.iso8601 }
53
+ ))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
8
+ # catalog — takes effect without a restart. Rejects a name that collides with a
9
+ # code tool (R3). Validating the definition (format, method/url, types,
10
+ # placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
11
+ # in the store; the event carries only the name (0 leakage). -> { name, definition }.
12
+ class WriteDataTool
13
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
14
+ @tool_store = tool_store
15
+ @registry = registry
16
+ @tool_catalog = tool_catalog
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = AgentPayload.symbolize(command.payload)
22
+ name = AgentPayload.presence(p[:name])
23
+ raise Insika::ValidationError, "name is required" if name.nil?
24
+ raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
25
+
26
+ masked = @tool_store.write(p, create_only: !!p[:create_only])
27
+ @registry.reload
28
+ @tool_catalog.reload
29
+ emit(name)
30
+ { name: name, definition: masked }
31
+ end
32
+
33
+ private
34
+
35
+ def emit(name)
36
+ @event_stream.emit(Insika::Event.new(
37
+ type: :data_tool_written, data: { name: name },
38
+ meta: { at: Time.now.utc.iso8601 }
39
+ ))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes an eval case into the GoldenStore. This
8
+ # is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
9
+ # that is plain language — without a checkout and a text editor.
10
+ #
11
+ # The payload carries the case as DATA (`case:`), the same mapping the YAML file
12
+ # holds; the store validates it with the one loader, so a case authored here and a
13
+ # case read from `evals/golden/` cannot diverge. -> { id, agent }.
14
+ class WriteGolden
15
+ def initialize(golden_store:, event_stream:)
16
+ @goldens = golden_store
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ def call(command)
21
+ p = AgentPayload.symbolize(command.payload)
22
+ raise Insika::ValidationError, "case is required" unless p[:case].is_a?(Hash)
23
+
24
+ golden = @goldens.write(p[:case])
25
+ emit(:golden_written, id: golden.id, agent: golden.agent)
26
+ { id: golden.id, agent: golden.agent }
27
+ end
28
+
29
+ private
30
+
31
+ def emit(type, **data)
32
+ @event_stream.emit(Insika::Event.new(type: type, data: data,
33
+ meta: { at: Time.now.utc.iso8601 }))
34
+ end
35
+ end
36
+
37
+ # Control command: removes an eval case. The corpus on disk is the way back
38
+ # (`insika evals:import`), which is why this needs no version history.
39
+ # -> { id, existed }.
40
+ class DeleteGolden
41
+ def initialize(golden_store:, event_stream:)
42
+ @goldens = golden_store
43
+ @event_stream = event_stream
44
+ end
45
+
46
+ def call(command)
47
+ p = AgentPayload.symbolize(command.payload)
48
+ id = AgentPayload.presence(p[:id])
49
+ raise Insika::ValidationError, "id is required" if id.nil?
50
+
51
+ existed = @goldens.delete(id)
52
+ @event_stream.emit(Insika::Event.new(type: :golden_deleted, data: { id: id, existed: existed },
53
+ meta: { at: Time.now.utc.iso8601 }))
54
+ { id: id, existed: existed }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "yaml"
5
+
6
+ module Insika
7
+ module Commands
8
+ # Control command: writes a skill (complete SKILL.md) into
9
+ # the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
10
+ # Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
11
+ #
12
+ # `agent` (optional) writes into that agent's scope instead of the shared one: the
13
+ # SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
14
+ # either way — the store position is the identity, so the frontmatter inside an
15
+ # override keeps saying the bare shared name and the level-1 catalog, load_skill,
16
+ # the labels and the events all keep displaying it.
17
+ class WriteSkill
18
+ def initialize(skill_store:, skill_catalog:, event_stream:)
19
+ @skill_store = skill_store
20
+ @skill_catalog = skill_catalog
21
+ @event_stream = event_stream
22
+ end
23
+
24
+ def call(command)
25
+ p = AgentPayload.symbolize(command.payload)
26
+ name = AgentPayload.presence(p[:name])
27
+ agent = AgentPayload.presence(p[:agent])
28
+ content = p[:content].to_s
29
+ raise Insika::ValidationError, "name is required" if name.nil?
30
+ validate_frontmatter!(content)
31
+
32
+ rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
33
+ @skill_catalog.reload
34
+ emit(:skill_written, name, agent)
35
+ { name: name, agent: agent, updated_at: rec["updated_at"] }
36
+ end
37
+
38
+ private
39
+
40
+ def emit(type, name, agent)
41
+ @event_stream.emit(Insika::Event.new(
42
+ type: type, data: { name: name, agent: agent }.compact,
43
+ meta: { at: Time.now.utc.iso8601 }
44
+ ))
45
+ end
46
+
47
+ # Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
48
+ # would be silently ignored on reload — fail early, in the Command.
49
+ def validate_frontmatter!(content)
50
+ match = content.match(/\A---\s*\n(.*?)\n---\s*\n/m)
51
+ raise Insika::ValidationError, "SKILL.md missing YAML frontmatter (--- ... ---)" unless match
52
+
53
+ # Tolerant parser (Frontmatter): prose with `: ` in the description is valid —
54
+ # it must not become a Psych::SyntaxError (500). Only fails if `name` is missing.
55
+ meta = Insika::Frontmatter.parse(match[1])
56
+ raise Insika::ValidationError, "frontmatter missing `name`" if AgentPayload.presence(meta["name"]).nil?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: writes a GLOBAL system file into the
8
+ # SystemFileStore. Unlike write_agent_file (per agent), these files
9
+ # apply to ALL agents — the Prompt provider injects them before the
10
+ # individual identity. Takes effect on the next turn (hot). -> { file, updated_at }.
11
+ class WriteSystemFile
12
+ def initialize(system_file_store:, event_stream:)
13
+ @system_files = system_file_store
14
+ @event_stream = event_stream
15
+ end
16
+
17
+ def call(command)
18
+ p = AgentPayload.symbolize(command.payload)
19
+ file = AgentPayload.presence(p[:file])
20
+ raise Insika::ValidationError, "file is required" if file.nil?
21
+
22
+ entry = @system_files.write(file, p[:content].to_s, create_only: !!p[:create_only])
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :system_file_written, data: { file: file },
25
+ meta: { at: Time.now.utc.iso8601 }
26
+ ))
27
+ { file: file, updated_at: entry["updated_at"] }
28
+ end
29
+ end
30
+ end
31
+ end