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,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 (Phase 7, Step B).
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 (NF3). Idempotent
11
+ # (re-importing reconciles). Injects the deployment's `{{secret.*}}`/`{{env.*}}`
12
+ # resolvers (D6/open-q2): 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 Phase 6 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,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 (RFC-0013 §3.6): 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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: restores an old version of a
8
+ # prompt file as the current content (a new write — history is
9
+ # linear, no rewinding). `version` = index into `versions` (0 = most recent
10
+ # old one). -> { agent_id, file, updated_at }.
11
+ class RestoreAgentFile
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
+ raise Insika::ValidationError, "version is required" if p[:version].nil?
25
+
26
+ entry = @agent_files.restore(agent_id, file, p[:version])
27
+ @event_stream.emit(Insika::Event.new(
28
+ type: :agent_file_restored,
29
+ data: { agent_id: agent_id, file: file, version: Integer(p[:version]) },
30
+ meta: { at: Time.now.utc.iso8601 }
31
+ ))
32
+ { agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Restores an old version of a data tool (a new write) and reloads
8
+ # overlay + catalog. The ToolStore raises NotFound/Validation. -> { name,
9
+ # definition (masked) }.
10
+ class RestoreDataTool
11
+ def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
12
+ @tool_store = tool_store
13
+ @registry = registry
14
+ @tool_catalog = tool_catalog
15
+ @event_stream = event_stream
16
+ end
17
+
18
+ def call(command)
19
+ p = AgentPayload.symbolize(command.payload)
20
+ name = AgentPayload.presence(p[:name])
21
+ raise Insika::ValidationError, "name is required" if name.nil?
22
+
23
+ masked = @tool_store.restore(name, p[:index])
24
+ @registry.reload
25
+ @tool_catalog.reload
26
+ @event_stream.emit(Insika::Event.new(
27
+ type: :data_tool_restored, data: { name: name },
28
+ meta: { at: Time.now.utc.iso8601 }
29
+ ))
30
+ { name: name, definition: masked }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command: restores an old version of a
8
+ # global system file as the current content (a new write — linear
9
+ # history). -> { file, updated_at }.
10
+ class RestoreSystemFile
11
+ def initialize(system_file_store:, event_stream:)
12
+ @system_files = system_file_store
13
+ @event_stream = event_stream
14
+ end
15
+
16
+ def call(command)
17
+ p = AgentPayload.symbolize(command.payload)
18
+ file = AgentPayload.presence(p[:file])
19
+ raise Insika::ValidationError, "file is required" if file.nil?
20
+ raise Insika::ValidationError, "version is required" if p[:version].nil?
21
+
22
+ entry = @system_files.restore(file, p[:version])
23
+ @event_stream.emit(Insika::Event.new(
24
+ type: :system_file_restored, data: { file: file, version: p[:version].to_s },
25
+ meta: { at: Time.now.utc.iso8601 }
26
+ ))
27
+ { file: file, updated_at: entry["updated_at"] }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Commands
5
+ # Turn command: resumes a task from the latest checkpoint. Crash-recovery
6
+ # and manual resume both use THIS same path (Recovery only discovers and dispatches).
7
+ # Re-executes the entire checkpointed turn; non-idempotent tools that already
8
+ # completed are skipped via the side-effect registry.
9
+ class ResumeTask
10
+ def initialize(profiles:, task_store:, checkpoint_store:, executor:)
11
+ @profiles = ProfileSource.coerce(profiles)
12
+ @task_store = task_store
13
+ @checkpoint_store = checkpoint_store
14
+ @executor = executor
15
+ end
16
+
17
+ def call(command)
18
+ task_id = (command.payload[:task_id] || command.payload["task_id"]).to_s
19
+ raise Insika::ValidationError, "task_id is required" if task_id.empty?
20
+
21
+ task = @task_store.find(task_id) ||
22
+ (raise Insika::NotFoundError, "task '#{task_id}' not found")
23
+
24
+ # IN-PROCESS RESUME: a :paused task whose fiber is STILL alive (blocked
25
+ # on await) — do NOT re-dispatch (spawn would duplicate the fiber). Just post :resume;
26
+ # the fiber transitions paused->running and continues. (A live :waiting is resolved
27
+ # by ApproveAction, not here.)
28
+ if task.status == :paused && @executor.running?(task_id)
29
+ @executor.resume_live(task_id)
30
+ return { task_id: task_id }
31
+ end
32
+
33
+ # :queued: a turn that was in the SessionActor queue and never
34
+ # started at crash time (no checkpoint) — recovering = RUN from scratch, from the
35
+ # original Command. Profile comes from the agent in the Command itself.
36
+ if task.status == :queued
37
+ @executor.spawn_in_session(task, profile: profile_for(task), resume_from: nil)
38
+ return { task_id: task_id }
39
+ end
40
+
41
+ # RE-DISPATCH (crash-resume): no live fiber, re-executes from the checkpoint.
42
+ # resume requires a checkpoint; without one the task is unrecoverable (Recovery
43
+ # would already have marked it :failed during the sweep).
44
+ checkpoint = @checkpoint_store.latest(task_id) ||
45
+ (raise Insika::ValidationError,
46
+ "task '#{task_id}' has no checkpoint — unrecoverable")
47
+
48
+ check_eligibility!(task)
49
+
50
+ # profile comes from the checkpoint (agent_id): the agent may have been removed from
51
+ # the config between the crash and boot -> fail loudly and clearly.
52
+ profile = @profiles[checkpoint.agent_id] ||
53
+ (raise Insika::NotFoundError, "agent '#{checkpoint.agent_id}' not configured")
54
+
55
+ @executor.spawn_in_session(task, profile: profile, resume_from: checkpoint)
56
+ { task_id: task_id }
57
+ end
58
+
59
+ private
60
+
61
+ # Profile for re-running a :queued task: from the agent in the persisted Command
62
+ # (there is no checkpoint). NotFoundError if the agent left the config.
63
+ def profile_for(task)
64
+ payload = task.command["payload"] || task.command[:payload] || {}
65
+ agent = (payload["agent"] || payload[:agent]).to_s
66
+ @profiles[agent] || (raise Insika::NotFoundError, "agent '#{agent}' not configured")
67
+ end
68
+
69
+ # Eligibility matrix: paused/waiting always; running only when
70
+ # orphaned (no live fiber IN THIS process — single-node); :queued is handled
71
+ # earlier (re-run); terminal states are not resumable.
72
+ def check_eligibility!(task)
73
+ case task.status
74
+ when :paused, :waiting
75
+ nil
76
+ when :running
77
+ raise Insika::ValidationError, "task '#{task.id}' is running" if @executor.running?(task.id)
78
+ else # terminal states (:completed, :failed, :cancelled)
79
+ raise Insika::ValidationError,
80
+ "task '#{task.id}' with status '#{task.status}' is not resumable"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (RFC-0013 phase A): reads a window of the agent's real traffic
8
+ # and records a ranked failure REPORT. Synchronous — it only scans durable stores
9
+ # (no provider call, no fiber), so it answers with the Run and does not create a
10
+ # Task. It is the ONLY way a refinement run starts: the CLI, the Studio button
11
+ # and any external cron all dispatch this one command (§3.10 — there is no
12
+ # scheduler in the engine).
13
+ #
14
+ # Payload:
15
+ # agent (required) agent id
16
+ # since ISO8601 — only turns from this instant on
17
+ # last_sessions Integer — the N most recent conversations instead
18
+ # full truthy — ignore the previous run and use the full window
19
+ # max_findings Integer cap on the report
20
+ # exclude_sessions [prefix] — session ids to drop (load tests, debug traffic)
21
+ #
22
+ # Window resolution (first that applies): explicit payload -> INCREMENTAL (since
23
+ # the previous run for this agent, unless `full`) -> the agent's configured
24
+ # `refinement.window` -> the collector's default.
25
+ #
26
+ # Phase A writes NOTHING to the agent, so it needs no opt-in: an absent
27
+ # `refinement` config reads as report-only. Only `propose`/`auto_apply` (phase C)
28
+ # require the operator to enable them explicitly.
29
+ class RunRefinement
30
+ READ_ONLY_MODES = %w[report propose auto_apply].freeze
31
+
32
+ def initialize(profiles:, refinement_store:, collector:, event_stream:)
33
+ @profiles = ProfileSource.coerce(profiles)
34
+ @refinement_store = refinement_store
35
+ @collector = collector
36
+ @event_stream = event_stream
37
+ end
38
+
39
+ def call(command)
40
+ p = AgentPayload.symbolize(command.payload)
41
+ agent = AgentPayload.presence(p[:agent])
42
+ raise Insika::ValidationError, "agent is required" if agent.nil?
43
+
44
+ profile = @profiles[agent] ||
45
+ (raise Insika::NotFoundError, "agent '#{agent}' not configured")
46
+ config = refinement_config(profile)
47
+ validate_mode!(config)
48
+
49
+ window = resolve_window(agent, p, config)
50
+ run = @refinement_store.create(agent_id: agent, window: window)
51
+ emit(:refinement_started, agent: agent, run_id: run.id, window: window)
52
+
53
+ begin
54
+ report = @collector.collect(
55
+ agent_id: agent, max_findings: max_findings(p, config),
56
+ exclude_sessions: exclude_sessions(p, config), **collect_args(window)
57
+ )
58
+ done = @refinement_store.complete(run.id, findings: report.findings,
59
+ excluded: report.excluded)
60
+ emit(:refinement_report, agent: agent, run_id: run.id, status: done.status,
61
+ findings: done.findings_count,
62
+ sessions: report.sessions_seen, turns: report.turns_seen,
63
+ excluded: report.excluded)
64
+ done
65
+ rescue StandardError => e
66
+ @refinement_store.fail(run.id, error: e.message)
67
+ raise
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def refinement_config(profile)
74
+ raw = profile.respond_to?(:refinement) ? profile.refinement : nil
75
+ raw.is_a?(Hash) ? raw : {}
76
+ end
77
+
78
+ # An unknown mode is a config typo, and a typo that silently degrades to
79
+ # "report" would be the kind of quiet wrong the strict-config rule exists to
80
+ # prevent (item 23).
81
+ def validate_mode!(config)
82
+ mode = AgentPayload.presence(config["mode"]) || "report"
83
+ return if READ_ONLY_MODES.include?(mode)
84
+
85
+ raise Insika::ValidationError,
86
+ "unknown refinement mode: #{mode} (expected #{READ_ONLY_MODES.join(', ')})"
87
+ end
88
+
89
+ def resolve_window(agent, payload, config)
90
+ since = AgentPayload.presence(payload[:since])
91
+ return { "since" => since } if since
92
+ return { "last_sessions" => Integer(payload[:last_sessions]) } if payload[:last_sessions]
93
+
94
+ # `full` arrives as true (internal dispatch) or "1" (Studio form) — one
95
+ # reading of a boolean, the same one item #131 settled on.
96
+ unless Insika::EnvSchema.truthy?(payload[:full])
97
+ previous = @refinement_store.latest_for(agent)
98
+ return { "since" => previous.started_at } if previous&.started_at
99
+ end
100
+
101
+ configured = config.dig("window", "last_sessions")
102
+ configured ? { "last_sessions" => Integer(configured) } : {}
103
+ end
104
+
105
+ # Window (as recorded) -> collector kwargs. {} = the collector's own default.
106
+ def collect_args(window)
107
+ return { since: window["since"] } if window["since"]
108
+ return { last_sessions: window["last_sessions"] } if window["last_sessions"]
109
+
110
+ {}
111
+ end
112
+
113
+ def max_findings(payload, config)
114
+ raw = payload[:max_findings] || config["max_findings"]
115
+ raw ? Integer(raw) : Refinement::EvidenceCollector::DEFAULT_MAX_FINDINGS
116
+ end
117
+
118
+ # Session-id prefixes to drop (load tests, debug conversations). Configured per
119
+ # agent; a payload list overrides it for one run. Never defaulted — the report
120
+ # does not get to decide what counts as real traffic.
121
+ def exclude_sessions(payload, config)
122
+ Array(payload[:exclude_sessions] || config["exclude_sessions"]).map(&:to_s)
123
+ end
124
+
125
+ def emit(type, **data)
126
+ @event_stream.emit(Insika::Event.new(
127
+ type: type, data: data,
128
+ meta: { at: Time.now.utc.iso8601 }
129
+ ))
130
+ end
131
+ end
132
+ end
133
+ end