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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # DYNAMIC tool registry: composes the CODE registry (base, built at boot,
5
+ # immutable) with the DATA-DEFINED tools from the ToolStore. Drop-in for ToolRegistry —
6
+ # the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect?. Phase 5,
7
+ # Step B / D2.
8
+ #
9
+ # Rules:
10
+ # - COLLISION: the base (code) ALWAYS wins — a data-tool cannot hijack
11
+ # the name of a code tool (security, R3). The authoring Command also
12
+ # refuses to create with a colliding name (code_tool?), but the defense stays here.
13
+ # - HOT: `reload` re-reads the store and swaps the dynamic index atomically — a
14
+ # new/edited data-tool takes effect on the next turn without a restart (F5), mirroring
15
+ # SkillCatalog.reload. An in-flight turn has already captured the index.
16
+ # - PARITY (NF1): empty ToolStore ⇒ entries/resolve/side_effect? identical to the
17
+ # pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
18
+ #
19
+ # The data-tools enter as NORMAL Registry::Entry (optional: false) — they obey
20
+ # the same per-agent allow/deny as code tools; exposure is the operator's
21
+ # (the /tools matrix), not automatic just because they are "data-defined".
22
+ class OverlayToolRegistry
23
+ def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil)
24
+ @base = base
25
+ @tool_store = tool_store
26
+ @http = http
27
+ @egress = egress
28
+ @egress_options = egress_options
29
+ @event_stream = event_stream
30
+ end
31
+
32
+ # Base + dynamic, except dynamic ones that collide with the base (base wins).
33
+ def entries
34
+ @base.entries + dynamic.reject { |e| code_tool?(e.name) }
35
+ end
36
+
37
+ def names
38
+ (@base.names + dynamic.map(&:name)).uniq
39
+ end
40
+
41
+ # -> instance (base wins) | raise NotFoundError.
42
+ def resolve(name)
43
+ key = name.to_s
44
+ return @base.resolve(key) if code_tool?(key)
45
+
46
+ entry = dynamic.find { |e| e.name == key }
47
+ raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}" unless entry
48
+
49
+ entry.factory.call
50
+ end
51
+
52
+ # -> bool; consumed by ToolEnvelope (checkpoint/skip-on-resume). Base wins.
53
+ def side_effect?(name)
54
+ key = name.to_s
55
+ return @base.side_effect?(key) if code_tool?(key)
56
+
57
+ entry = dynamic.find { |e| e.name == key }
58
+ entry ? !!entry.metadata[:side_effect] : false
59
+ end
60
+
61
+ # Atomic swap of the dynamic index (after writing/removing a data-tool).
62
+ def reload
63
+ @dynamic = build_dynamic
64
+ self
65
+ end
66
+
67
+ # Is it a CODE tool (base)? Used by the authoring Command's validation.
68
+ def code_tool?(name) = @base.names.include?(name.to_s)
69
+
70
+ private
71
+
72
+ def dynamic
73
+ @dynamic ||= build_dynamic
74
+ end
75
+
76
+ def build_dynamic
77
+ @tool_store.all_raw.filter_map { |raw| entry_for(raw) }
78
+ end
79
+
80
+ def entry_for(raw)
81
+ definition = Insika::ToolDefinition.from_h(raw)
82
+ Insika::Registry::Entry.new(
83
+ name: definition.name, plugin: "data-tools",
84
+ metadata: { optional: false, side_effect: definition.side_effect,
85
+ group: definition.group, tags: definition.tags },
86
+ factory: -> { build_tool(definition) }
87
+ )
88
+ rescue Insika::ValidationError => e
89
+ warn "[overlay-tools] invalid definition ignored: #{e.message}"
90
+ nil
91
+ end
92
+
93
+ # Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
94
+ # out of insika.rb load-time, loaded on the 1st instance (turn time).
95
+ def build_tool(definition)
96
+ require_relative "tools/data_defined_tool"
97
+ Insika::Tools::DataDefinedTool.new(
98
+ definition: definition, http: @http, egress: @egress,
99
+ egress_options: @egress_options, event_stream: @event_stream
100
+ )
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # Provisioning pack (Phase 6/D4/F6): the PORTABLE form of an agent — a
7
+ # manifest + the prompt files + the skills + the data-tool defs. It's what
8
+ # `docs/prompt-base/06` describes as a workspace, here in a value object
9
+ # consumable by the PackImporter (which emits the authoring Commands). GENERIC
10
+ # per project (NF1): the engine doesn't know achei-b2b — the pack is the contract.
11
+ #
12
+ # config: Hash — manifest (AgentProfile.build attrs: id/model/provider/
13
+ # limits/metadata/tools_deferred/…). `id`/`model` required there.
14
+ # files: { "IDENTITY.md" => "<content>", ... } — prompt files (become
15
+ # prompt_files via write_agent_file). File name = key.
16
+ # skills: { "escalation-to-human" => "<SKILL.md>", ... } — 1 per skill.
17
+ # tools: [ { ToolDefinition hash }, ... ] — the pack's data-tool defs.
18
+ #
19
+ # Two sources: `from_h` (GatewayClient JSON — provisioning API, task 8) and
20
+ # `from_dir` (a folder on disk per docs/prompt-base/06 — authoring/CLI).
21
+ Pack = Data.define(:config, :files, :skills, :tools) do
22
+ # Raw Hash (string|symbol keys) -> Pack. Tolerates both key conventions
23
+ # (the JSON wire may arrive symbolized or not).
24
+ def self.from_h(hash)
25
+ h = hash || {}
26
+ new(
27
+ config: symbolize(dig(h, :config) || {}),
28
+ files: text_values!(stringify_keys(dig(h, :files) || {}), "files"),
29
+ skills: text_values!(stringify_keys(dig(h, :skills) || {}), "skills"),
30
+ tools: Array(dig(h, :tools))
31
+ )
32
+ end
33
+
34
+ # A pack's files and skills are NAME => MARKDOWN. Only the keys used to be
35
+ # normalized, so `{"files": {"AGENTS.md": {"content": "…"}}}` — the shape anyone
36
+ # writes first — was accepted and reached the store as Ruby's #inspect of the
37
+ # object. Refuse at the front door, where the name of the offending file is still
38
+ # in hand.
39
+ def self.text_values!(hash, field)
40
+ hash.each do |name, value|
41
+ next if value.is_a?(String)
42
+
43
+ raise Insika::ValidationError,
44
+ "pack #{field}['#{name}'] must be the file's text, got #{value.class} — " \
45
+ "a nested object (e.g. {\"content\": …}) is not unwrapped, it is stored verbatim"
46
+ end
47
+ end
48
+ private_class_method :text_values!
49
+
50
+ # Folder on disk (docs/prompt-base/06): `agent.config.json` + `*.md` at the
51
+ # root + `skills/<name>/SKILL.md` + `tools/*.json` (1 ToolDefinition per file).
52
+ def self.from_dir(path)
53
+ root = path.to_s
54
+ config_file = File.join(root, "agent.config.json")
55
+ raise Insika::ValidationError, "pack missing agent.config.json in #{root}" unless File.exist?(config_file)
56
+
57
+ new(
58
+ config: symbolize(JSON.parse(File.read(config_file, encoding: "UTF-8"))),
59
+ files: read_md_files(root),
60
+ skills: read_skills(File.join(root, "skills")),
61
+ tools: read_tools(File.join(root, "tools"))
62
+ )
63
+ end
64
+
65
+ def self.dig(hash, key) = hash[key] || hash[key.to_s]
66
+ private_class_method :dig
67
+
68
+ def self.symbolize(hash)
69
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
70
+ end
71
+ private_class_method :symbolize
72
+
73
+ def self.stringify_keys(hash)
74
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
75
+ end
76
+ private_class_method :stringify_keys
77
+
78
+ # `*.md` at the pack ROOT (doesn't descend into skills/). File name = key.
79
+ def self.read_md_files(root)
80
+ Dir.glob(File.join(root, "*.md")).sort.each_with_object({}) do |file, acc|
81
+ acc[File.basename(file)] = File.read(file, encoding: "UTF-8")
82
+ end
83
+ end
84
+ private_class_method :read_md_files
85
+
86
+ # `skills/<name>/SKILL.md` -> { "<name>" => "<content>" }.
87
+ def self.read_skills(dir)
88
+ Dir.glob(File.join(dir, "*", "SKILL.md")).sort.each_with_object({}) do |file, acc|
89
+ acc[File.basename(File.dirname(file))] = File.read(file, encoding: "UTF-8")
90
+ end
91
+ end
92
+ private_class_method :read_skills
93
+
94
+ # `tools/*.json` -> [ ToolDefinition hash, ... ].
95
+ def self.read_tools(dir)
96
+ Dir.glob(File.join(dir, "*.json")).sort.map do |file|
97
+ JSON.parse(File.read(file, encoding: "UTF-8"))
98
+ end
99
+ end
100
+ private_class_method :read_tools
101
+ end
102
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Pack importer (Phase 6/D4/F6, task 7): reads a Pack and emits the ALREADY
5
+ # existing authoring Commands — create_agent/update_agent + write_agent_file +
6
+ # write_skill + write_data_tool — making an agent provisionable at runtime from
7
+ # a standardized pack. It's the piece that achei-b2b's `GatewayClient`/
8
+ # `ProvisionStore` triggers (via the provisioning API, task 8).
9
+ #
10
+ # GENERIC (NF1): nothing here mentions achei-b2b — the pack is the contract. Tool
11
+ # names come from the PACK, so prompts<->tools stay consistent by construction
12
+ # (NF2). It doesn't write to the store directly: it only dispatches Commands on
13
+ # the bus (the same transport discipline) + READS the ProfileSource to decide
14
+ # create vs update.
15
+ #
16
+ # IDEMPOTENT (upsert): re-importing the same pack reconciles — files/skills/
17
+ # tools rewritten; the allowlists (prompt_files/skills/tools_allow) are
18
+ # AUTHORITATIVE from the pack, so whatever left the pack leaves the agent
19
+ # (isolation and no drift). An in-flight turn keeps the profile it captured.
20
+ class PackImporter
21
+ def initialize(bus:, profiles:)
22
+ @bus = bus
23
+ @profiles = profiles
24
+ end
25
+
26
+ # -> { agent_id:, created:, files: [names], skills: [names], tools: [names] }.
27
+ # Validation/lookup errors from the Commands (missing id/model, etc.) propagate
28
+ # (the transport maps them to 422/404).
29
+ def import(pack)
30
+ id = presence(pack.config[:id]) ||
31
+ (raise Insika::ValidationError, "pack missing config.id")
32
+
33
+ created = @profiles[id].nil?
34
+ # create_agent rejects an already-existing id; update_agent requires it to
35
+ # exist — the choice by presence makes the import an upsert.
36
+ dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id))
37
+
38
+ pack.files.each { |name, body| dispatch(:write_agent_file, { agent_id: id, file: name, content: body }) }
39
+ pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body }) }
40
+ pack.tools.each { |defn| dispatch(:write_data_tool, defn) }
41
+
42
+ { agent_id: id, created: created,
43
+ files: pack.files.keys, skills: pack.skills.keys, tools: pack.tools.map { |t| tool_name(t) } }
44
+ end
45
+
46
+ # Removes the agent (delete_agent). Does NOT delete skills/tools/files: they
47
+ # may be shared and the SkillStore/ToolStore are global — selective removal is
48
+ # operator work. NotFoundError (missing agent) propagates -> 404.
49
+ def delete(id)
50
+ dispatch(:delete_agent, { id: id })
51
+ { agent_id: id, deleted: true }
52
+ end
53
+
54
+ private
55
+
56
+ # AgentProfile.build attrs: the pack manifest + the allowlists derived and
57
+ # AUTHORITATIVE from the pack. This way the agent only sees the skills/tools of
58
+ # its OWN pack (per-store isolation) and re-provisioning removes what left.
59
+ # - prompt_files = the pack's .md files (write_agent_file also registers;
60
+ # union is a no-op). Setting here makes the list authoritative (removes the ones that left).
61
+ # - skills = the pack's skills/ dirs (explicit allowlist; [] if none in the
62
+ # pack and config didn't declare any — never nil=all, which would leak
63
+ # skills from other stores).
64
+ # - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
65
+ # the agent can call its own data-tools (NF2).
66
+ # - tools_allow_groups = groups enabled by FLAG in the pack (see
67
+ # #enabled_groups) — the per-flag schema CUT (Phase 7, Stage E / D5): only
68
+ # the tools of the enabled groups (union with tools_allow) go to the model;
69
+ # those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
70
+ # tool-call waste, where the flag only exists in Rails).
71
+ def agent_attrs(pack, id)
72
+ attrs = pack.config.dup
73
+ attrs[:id] = id
74
+ attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
75
+ attrs[:skills] = pack.skills.keys if pack.skills.any? || pack.config.key?(:skills)
76
+
77
+ pack_tools = pack.tools.map { |t| tool_name(t) }
78
+ allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
79
+ attrs[:tools_allow] = allow unless allow.empty? && !pack.config.key?(:tools_allow)
80
+
81
+ groups = enabled_groups(pack.config)
82
+ attrs[:tools_allow_groups] = groups unless groups.nil?
83
+ attrs
84
+ end
85
+
86
+ # PER-FLAG CUT (Phase 7, Stage E / D5, STATIC pilot): derives the agent's
87
+ # per-group allowlist from FLAGS declared in the pack config — DATA, never a
88
+ # core convention (NF1). The engine doesn't know "groceries_v2"/"b2b": the pack
89
+ # declares which GROUPS are enabled; the flag->group mapping is the
90
+ # responsibility of provisioning/the pack, not the insika. Two forms (union):
91
+ # - `enabled_groups: ["default", "b2b"]` — explicit list of ON groups.
92
+ # - `flags: { "b2b" => true, "natura" => false }` — the flag key IS the group
93
+ # name; only the truthy ones enter (the false ones CUT the group).
94
+ # Neither declared -> nil (no per-group cut; old behavior).
95
+ # Explicit `[]` (empty enabled_groups, or all flags false) -> no group.
96
+ def enabled_groups(config)
97
+ return nil unless config.key?(:enabled_groups) || config.key?(:flags)
98
+
99
+ from_list = Array(config[:enabled_groups]).map(&:to_s)
100
+ from_flags = flag_groups(config[:flags])
101
+ (from_list | from_flags)
102
+ end
103
+
104
+ # { group => bool } -> [groups with a truthy value]. Accepts string|symbol keys.
105
+ def flag_groups(flags)
106
+ return [] unless flags.is_a?(Hash)
107
+
108
+ flags.filter_map { |group, on| group.to_s if truthy?(on) }
109
+ end
110
+
111
+ def truthy?(val) = val == true || val.to_s == "true"
112
+
113
+ def tool_name(defn) = (defn[:name] || defn["name"]).to_s
114
+
115
+ def presence(str) = Insika::Coercion.presence(str)
116
+
117
+ def dispatch(type, payload)
118
+ @bus.dispatch(Insika::Command.build(type, payload, transport: :http))
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Domain store for PENDING approval ACTIONS ("state as a
8
+ # record, not a flag"). A tool marked `approval` (Policy) creates a
9
+ # PendingAction and the turn goes to :waiting; the operator resolves it via ApproveAction.
10
+ # Durable (over an injected Insika::Store): survives a kill -9 — the operator
11
+ # approves after the reboot, and Recovery rehydrates the task in :waiting.
12
+ #
13
+ # Normalizes symbol→string on WRITE (the backend only guarantees round-trip of JSON
14
+ # types), like the other domain stores.
15
+ class PendingActionStore
16
+ include Coercion
17
+
18
+ SCOPE = "pending_actions"
19
+ KEY_PREFIX = "pending:"
20
+
21
+ STATUSES = %i[pending approved rejected].freeze
22
+
23
+ PendingAction = Data.define(:id, :task_id, :turn, :tool, :args,
24
+ :status, :requested_at, :resolved_by, :resolved_at)
25
+
26
+ def initialize(store:)
27
+ @store = store
28
+ end
29
+
30
+ # -> PendingAction (:pending). `args` is the Hash of the tool call's arguments.
31
+ def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
32
+ record = {
33
+ "id" => id.to_s,
34
+ "task_id" => task_id.to_s,
35
+ "turn" => turn,
36
+ "tool" => tool.to_s,
37
+ "args" => deep_stringify(args),
38
+ "status" => "pending",
39
+ "requested_at" => timestamp,
40
+ "resolved_by" => nil,
41
+ "resolved_at" => nil
42
+ }
43
+ @store.set(SCOPE, key_for(id), record)
44
+ to_pending(record)
45
+ end
46
+
47
+ # -> PendingAction | nil
48
+ def find(id)
49
+ record = @store.get(SCOPE, key_for(id))
50
+ record && to_pending(record)
51
+ end
52
+
53
+ # -> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node,
54
+ # like TaskStore#running_or_interrupted.
55
+ def open_for(task_id)
56
+ id = task_id.to_s
57
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
58
+ record = @store.get(SCOPE, key)
59
+ next if record.nil?
60
+
61
+ pa = to_pending(record)
62
+ pa if pa.status == :pending && pa.task_id == id
63
+ end
64
+ end
65
+
66
+ # -> [PendingAction] every :pending across all tasks — the approvals inbox
67
+ # (§12 G5, Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
68
+ # UI resolves task context afterwards via TaskStore#find.
69
+ def all_open
70
+ @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
71
+ record = @store.get(SCOPE, key)
72
+ next if record.nil?
73
+
74
+ pa = to_pending(record)
75
+ pa if pa.status == :pending
76
+ end
77
+ end
78
+
79
+ # -> resolved PendingAction. Only resolves :pending: a double resolution
80
+ # or an invalid decision -> ValidationError; absent -> NotFoundError.
81
+ def resolve(id, decision:, operator: nil)
82
+ target = decision.to_sym
83
+ unless %i[approved rejected].include?(target)
84
+ raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
85
+ end
86
+
87
+ record = @store.get(SCOPE, key_for(id)) ||
88
+ (raise Insika::NotFoundError, "pending action not found: #{id}")
89
+ unless record["status"] == "pending"
90
+ raise Insika::ValidationError, "pending action '#{id}' already resolved (#{record["status"]})"
91
+ end
92
+
93
+ record["status"] = target.to_s
94
+ record["resolved_by"] = operator&.to_s
95
+ record["resolved_at"] = timestamp
96
+ @store.set(SCOPE, key_for(id), record)
97
+ to_pending(record)
98
+ end
99
+
100
+ private
101
+
102
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
103
+
104
+ def to_pending(record)
105
+ PendingAction.new(
106
+ id: record["id"],
107
+ task_id: record["task_id"],
108
+ turn: record["turn"],
109
+ tool: record["tool"],
110
+ args: record["args"],
111
+ status: record["status"].to_sym,
112
+ requested_at: record["requested_at"],
113
+ resolved_by: record["resolved_by"],
114
+ resolved_at: record["resolved_at"]
115
+ )
116
+ end
117
+
118
+ def timestamp = Time.now.utc.iso8601
119
+ end
120
+ end