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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # MCP instances authored at runtime. One
5
+ # record per instance in the ConfigStore (scope "mcp"), keyed by the name slug
6
+ # (`tavily`, `github`, ...). Holds transport/command/url, the `enabled` flag and
7
+ # a Hash of `env` credentials (tokens/keys the instance injects into the server).
8
+ #
9
+ # The credentials (`env`) NEVER leave here in plaintext to the UI: the display
10
+ # reads (`get`/`all`) mask EACH value with the `__OCULTO__` sentinel. Only
11
+ # `get_raw`/`all_raw` (consumed by an MCP client, never by the screen) return
12
+ # the real values. On write, the sentinel coming back preserves the value; a new
13
+ # string replaces it; "" clears it (see Insika::SecretMasking, the same pattern
14
+ # as the LLM api_keys).
15
+ #
16
+ # Current scope: durable config CRUD (the instances UI). Running an MCP client
17
+ # against these instances is later runtime work — the store is the editable
18
+ # source from now on.
19
+ class McpStore
20
+ include Coercion
21
+
22
+ SCOPE = "mcp"
23
+
24
+ def initialize(config_store:)
25
+ @cs = config_store
26
+ end
27
+
28
+ # -> MASKED Hash (env with sentinel) | nil.
29
+ def get(name)
30
+ mask(raw(name))
31
+ end
32
+
33
+ # -> Hash with REAL env | nil. Internal use (MCP client), never the screen.
34
+ def get_raw(name)
35
+ raw(name)
36
+ end
37
+
38
+ # -> [String] slugs, lexicographic order.
39
+ def names = @cs.keys(SCOPE)
40
+
41
+ # -> [Hash] all MASKED (for the UI).
42
+ def all
43
+ names.filter_map { |n| get(n) }
44
+ end
45
+
46
+ # -> [Hash] all with REAL env (for an MCP client). Never goes to the screen.
47
+ def all_raw
48
+ names.filter_map { |n| raw(n) }
49
+ end
50
+
51
+ # Upsert with per-env-key secret reconciliation. `attrs`
52
+ # (string|symbol keys):
53
+ # name (required), transport, command, url, description,
54
+ # enabled (bool), env ({ "KEY" => value|sentinel|"" })
55
+ # -> MASKED Hash (the stored record).
56
+ def upsert(attrs)
57
+ h = symbolize(attrs)
58
+ name = presence(h[:name])
59
+ raise Insika::ValidationError, "name is required" if name.nil?
60
+
61
+ existing = raw(name)
62
+ record = {
63
+ "name" => name,
64
+ "transport" => presence(h[:transport]) || "stdio",
65
+ "command" => presence(h[:command]),
66
+ "url" => presence(h[:url]),
67
+ "description" => presence(h[:description]),
68
+ "enabled" => h.fetch(:enabled, true) ? true : false,
69
+ "env" => reconcile_env(h[:env], existing && existing["env"])
70
+ }
71
+ @cs.put(SCOPE, name, record)
72
+ mask(record)
73
+ end
74
+
75
+ # -> bool (did it exist?).
76
+ def delete(name) = @cs.delete(SCOPE, name.to_s)
77
+
78
+ private
79
+
80
+ def raw(name) = @cs.get(SCOPE, name.to_s)
81
+
82
+ # Each env value becomes the sentinel (or disappears if empty) — never leaks plaintext.
83
+ def mask(record)
84
+ return nil if record.nil?
85
+
86
+ env = (record["env"] || {}).each_with_object({}) do |(k, v), acc|
87
+ acc[k] = SecretMasking.mask(v)
88
+ end
89
+ record.merge("env" => env)
90
+ end
91
+
92
+ # Reconciles the env received from the form against the stored one, key by key: a
93
+ # key that came as the sentinel is preserved; a new string replaces it; "" (or missing
94
+ # from the submission) clears it. NEW keys are added; old keys absent from the form
95
+ # are removed (the form sends the complete set of keys).
96
+ def reconcile_env(incoming, existing)
97
+ inc = stringify_hash(incoming)
98
+ old = existing || {}
99
+ inc.each_with_object({}) do |(k, v), acc|
100
+ value = SecretMasking.reconcile(v, old[k])
101
+ acc[k] = value unless value.nil? || value.to_s.empty?
102
+ end
103
+ end
104
+
105
+ def stringify_hash(obj)
106
+ return {} unless obj.is_a?(Hash)
107
+
108
+ obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
109
+ end
110
+
111
+ def symbolize(attrs)
112
+ (attrs || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # LIVE MCP ingestion (Phase 7, Stage E / spec §4 D8): discovers the tools of an
7
+ # MCP instance at RUNTIME (no hand-written manifest) and ingests them as
8
+ # data-tools. Given an McpStore instance + an INJECTABLE MCP client
9
+ # (duck-typed: `#list_tools -> [{name, description, inputSchema}]`), it builds a
10
+ # ToolManifest and REUSES the Stage B ingestion path (the :import_tools Command:
11
+ # batch upsert into the ToolStore + hot reload + per-tool report + partial-
12
+ # failure isolation R4). The ToolManifest MCP adapter (`inputSchema`) is reused
13
+ # — no schema parsing here.
14
+ #
15
+ # GENERIC (NF1): nothing here mentions achei/openclaw. The MCP instance is DATA in the store.
16
+ #
17
+ # BINDING STRATEGY (this stage's choice, bounded):
18
+ # Each discovered tool becomes an HTTP data-tool that makes a JSON-RPC 2.0
19
+ # `tools/call` POST to the instance endpoint (url). The tool name is resolved
20
+ # at INGESTION (literal in the body); the model's arguments come in as `{{param}}`
21
+ # per TOP-level property of the inputSchema (with quoting by type — strings
22
+ # quoted, others raw, via the DataDefinedTool :body encode). This way the tool
23
+ # runs through the SAME HTTP path as the other data-tools (egress guard, secret
24
+ # headers, hot reload) — no new execution code.
25
+ #
26
+ # Each tool gets `group: "mcp:<instance>"` so the Stage C per-group gating
27
+ # (tools_allow_groups) works for free.
28
+ #
29
+ # DEFERRED / OUT-OF-SCOPE (documented — spec §4 D8):
30
+ # - Real MCP transport: only instances with a `url` (http transport) are ingestible;
31
+ # stdio has no HTTP endpoint -> raises a clear error (later work).
32
+ # - MCP session lifecycle (initialize/negotiation/session-id/notifications) and the
33
+ # UNWRAP of the `tools/call` response (`{content:[{type,text}]}`) — the binding
34
+ # makes a stateless POST and returns the raw body (extract body_raw).
35
+ # - Credential injection (the instance `env`) as an auth header in the HTTP
36
+ # binding: the `env` is consumed by a real MCP client (deferred), not mapped
37
+ # to a header here.
38
+ # - Tools whose name/top-level property is outside the ToolDefinition NAME_RE
39
+ # (uppercase/hyphens) are ISOLATED into `errors[]` by the import (R4).
40
+ class McpToolIngestor
41
+ def initialize(mcp_store:, import_tools:, client_factory: nil)
42
+ @mcp_store = mcp_store
43
+ @import_tools = import_tools
44
+ # Per-instance client factory (default: minimal JSON-RPC HTTP client).
45
+ # Injectable for tests (Fake) and to swap for a real transport later.
46
+ @client_factory = client_factory || method(:default_client)
47
+ end
48
+
49
+ # Discovers + ingests the tools of instance `name`. `client` is injectable
50
+ # (Fake in tests); absent -> the factory builds one from the record. -> the
51
+ # import_tools report + `instance:` ({ instance:, version:, created:, updated:, errors: }).
52
+ def ingest(name, client: nil)
53
+ manifest = manifest_for(name, client: client)
54
+ report = @import_tools.call(Insika::Command.build(:import_tools, manifest, transport: :internal))
55
+ report.merge(instance: name.to_s)
56
+ end
57
+
58
+ # Discovers the tools and builds the manifest Hash (without ingesting) — isolable for testing.
59
+ def manifest_for(name, client: nil)
60
+ record = @mcp_store.get_raw(name.to_s)
61
+ raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
62
+ raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]
63
+
64
+ url = presence(record["url"])
65
+ if url.nil?
66
+ raise Insika::ValidationError,
67
+ "MCP instance '#{name}' has no url: live ingestion requires HTTP transport " \
68
+ "(stdio is later work — D8)"
69
+ end
70
+
71
+ tools = Array((client || @client_factory.call(record)).list_tools)
72
+ build_manifest(name.to_s, url, tools)
73
+ end
74
+
75
+ private
76
+
77
+ def build_manifest(name, url, tools)
78
+ {
79
+ "version" => 1,
80
+ "defaults" => {
81
+ "method" => "POST",
82
+ "headers" => { "Content-Type" => "application/json" },
83
+ "response" => { "extract" => "body_raw" },
84
+ "group" => "mcp:#{name}"
85
+ },
86
+ "tools" => tools.map { |raw| tool_entry(name, url, stringify(raw)) }
87
+ }
88
+ end
89
+
90
+ # Raw MCP entry -> manifest entry (MCP `inputSchema` envelope + JSON-RPC
91
+ # binding). ToolManifest normalizes the `inputSchema` (MCP adapter) and
92
+ # inherits the defaults; `group` falls through from the defaults.
93
+ def tool_entry(name, url, raw)
94
+ tool_name = raw["name"]
95
+ input_schema = raw["inputSchema"] || {}
96
+ {
97
+ "name" => tool_name,
98
+ "description" => presence(raw["description"]) || "Tool '#{tool_name}' from MCP server '#{name}'.",
99
+ "inputSchema" => input_schema,
100
+ "url" => url,
101
+ "side_effect" => true, # a tools/call is a side effect (checkpoint/skip-on-resume)
102
+ "body" => jsonrpc_call_body(tool_name, input_schema)
103
+ }
104
+ end
105
+
106
+ # JSON-RPC 2.0 `tools/call` body. `name` literal (resolved at ingestion);
107
+ # `arguments` per TOP-level property of the inputSchema, with `{{param}}` that
108
+ # the DataDefinedTool interpolates at TURN time.
109
+ def jsonrpc_call_body(tool_name, input_schema)
110
+ %({"jsonrpc":"2.0","id":1,"method":"tools/call",) +
111
+ %("params":{"name":#{JSON.generate(tool_name)},"arguments":#{arguments_fragment(input_schema)}}})
112
+ end
113
+
114
+ # -> "{...}" JSON with one placeholder per top-level property. Strings are
115
+ # quoted (the DataDefinedTool :body encode returns the escaped content WITHOUT
116
+ # quotes); other types raw (the encode returns value.to_json).
117
+ def arguments_fragment(input_schema)
118
+ props = (input_schema["properties"] || input_schema[:properties] || {})
119
+ return "{}" if props.nil? || props.empty?
120
+
121
+ pairs = props.map do |key, spec|
122
+ type = stringify(spec)["type"].to_s
123
+ placeholder = type == "string" ? %("{{#{key}}}") : "{{#{key}}}"
124
+ %(#{JSON.generate(key.to_s)}:#{placeholder})
125
+ end
126
+ "{#{pairs.join(',')}}"
127
+ end
128
+
129
+ def default_client(record)
130
+ Insika::McpHttpClient.new(url: record["url"])
131
+ end
132
+
133
+ def presence(str) = Insika::Coercion.presence(str)
134
+
135
+ def stringify(obj)
136
+ case obj
137
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
138
+ when Array then obj.map { |v| stringify(v) }
139
+ else obj
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # AGENT-MEMORY DOMAIN store. Two layers
8
+ # scoped per tenant over any `Insika::Store`: `profile` (stable key-value
9
+ # facts) and `notes` (append-only free-form notes). Mirrors the
10
+ # `PendingActionStore` (normalizes symbol→string on write, O(n) scan on
11
+ # read, records with a timestamp).
12
+ #
13
+ # NOT to be confused with `Insika::Stores::Memory` (in-memory KV backend):
14
+ # this is the domain store; that one is one of the backends this writes to.
15
+ class MemoryStore
16
+ SCOPE_PREFIX = "memory" # scope = "memory:<tenant>"
17
+ FACT_PREFIX = "fact:"
18
+ NOTE_PREFIX = "note:"
19
+ DEFAULT_TENANT = "_default" # no tenant in the Command
20
+
21
+ Fact = Data.define(:key, :value, :updated_at)
22
+ Note = Data.define(:id, :text, :created_at)
23
+
24
+ def initialize(store:)
25
+ @store = store
26
+ end
27
+
28
+ # Upsert (last-write-wins, Store contract). -> Fact
29
+ def put_fact(tenant:, key:, value:)
30
+ record = { "key" => key.to_s, "value" => stringify(value), "updated_at" => timestamp }
31
+ @store.set(scope_for(tenant), FACT_PREFIX + key.to_s, record)
32
+ to_fact(record)
33
+ end
34
+
35
+ # -> Fact | nil
36
+ def get_fact(tenant:, key:)
37
+ record = @store.get(scope_for(tenant), FACT_PREFIX + key.to_s)
38
+ record && to_fact(record)
39
+ end
40
+
41
+ # -> [Fact] sorted by key (list is lexicographic).
42
+ def facts(tenant:)
43
+ scope = scope_for(tenant)
44
+ @store.list(scope, FACT_PREFIX).filter_map do |k|
45
+ record = @store.get(scope, k)
46
+ record && to_fact(record)
47
+ end
48
+ end
49
+
50
+ # -> bool (did it exist?)
51
+ def forget_fact(tenant:, key:)
52
+ @store.delete(scope_for(tenant), FACT_PREFIX + key.to_s)
53
+ end
54
+
55
+ # Append. `at` (ISO8601) goes at the START of the key so `list` returns the notes in
56
+ # chronological order; `id`/`at` injectable for deterministic tests. -> Note
57
+ def add_note(tenant:, text:, id: SecureRandom.uuid, at: nil)
58
+ at ||= timestamp
59
+ record = { "id" => id.to_s, "text" => text.to_s, "created_at" => at }
60
+ @store.set(scope_for(tenant), NOTE_PREFIX + "#{at}:#{id}", record)
61
+ to_note(record)
62
+ end
63
+
64
+ # -> [Note] MOST RECENT first, capped by `limit`.
65
+ def notes(tenant:, limit: nil)
66
+ scope = scope_for(tenant)
67
+ keys = @store.list(scope, NOTE_PREFIX).reverse # list is chronological -> reverse = most recent first
68
+ keys = keys.first(limit) if limit
69
+ keys.filter_map do |k|
70
+ record = @store.get(scope, k)
71
+ record && to_note(record)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def scope_for(tenant) = "#{SCOPE_PREFIX}:#{tenant.nil? || tenant.to_s.empty? ? DEFAULT_TENANT : tenant}"
78
+
79
+ def to_fact(record) = Fact.new(key: record["key"], value: record["value"], updated_at: record["updated_at"])
80
+ def to_note(record) = Note.new(id: record["id"], text: record["text"], created_at: record["created_at"])
81
+
82
+ def timestamp = Time.now.utc.iso8601
83
+
84
+ def stringify(obj)
85
+ case obj
86
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
87
+ when Array then obj.map { |v| stringify(v) }
88
+ when Symbol then obj.to_s
89
+ else obj
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WHO PRODUCED A TRANSCRIPT MESSAGE — the field a `role` cannot carry.
5
+ #
6
+ # `role` says where a message sits in the conversation, not who wrote it, and the
7
+ # two come apart constantly:
8
+ #
9
+ # · The engine delivers an async subagent's result to the parent as a NEW turn
10
+ # (RFC-0010) — a `user` message the engine wrote.
11
+ # · A guardrail short-circuits with a safe reply — an `assistant` message produced
12
+ # with zero LLM calls.
13
+ # · A consumer composes context blocks into the input it sends (`<memoria> …`,
14
+ # `<cacau_cep_obrigatorio> …`) — a `user` message the customer never typed.
15
+ # · In an imported transcript, a human operator types after a handoff — an
16
+ # `assistant` message no model produced.
17
+ #
18
+ # Reading a transcript without that distinction is not a rounding error. The first
19
+ # refinement run over real traffic reported `repetition ×219` on one agent: every
20
+ # one of them the engine reading its own injected fragment back and calling it a
21
+ # customer repeating themselves (PR #133). That was filtered by a REGEX on the
22
+ # leading tag, labelled in the code as a heuristic standing in for this field.
23
+ #
24
+ # ABSENT is the common case and stays valid forever: a message with no origin is
25
+ # read as the natural producer for its role (`user` → the customer, `assistant` →
26
+ # the agent). Nothing in the existing corpus, the stores or the pilot's database
27
+ # has to be migrated, and a reader that ignores the field is exactly as correct as
28
+ # it was before.
29
+ module MessageOrigin
30
+ KEY = "origin"
31
+
32
+ CUSTOMER = "customer" # a person on the user side (the default for `user`)
33
+ AGENT = "agent" # the model (the default for `assistant`)
34
+ ENGINE = "engine" # Insika itself, or the consumer composing on its behalf
35
+ OPERATOR = "operator" # a HUMAN on the assistant side (a handoff; set by importers)
36
+
37
+ ALL = [CUSTOMER, AGENT, ENGINE, OPERATOR].freeze
38
+
39
+ module_function
40
+
41
+ # A declared origin, or nil. Anything outside the closed set is REFUSED rather
42
+ # than stored: an unknown value would silently read as "absent" downstream, and
43
+ # a typo'd marker is worse than none — it looks like the filtering is on.
44
+ def parse!(value)
45
+ return nil if Coercion.blank?(value)
46
+
47
+ name = value.to_s.strip.downcase
48
+ return name if ALL.include?(name)
49
+
50
+ raise Insika::ValidationError,
51
+ "unknown message origin: #{value.inspect} (expected #{ALL.join(', ')})"
52
+ end
53
+
54
+ # Did a PERSON on the user side write this? Absent origin = yes, because that is
55
+ # what a `user` message meant before this field existed.
56
+ def customer?(message)
57
+ message["role"].to_s == "user" && [nil, CUSTOMER].include?(origin_of(message))
58
+ end
59
+
60
+ # Did the MODEL write this? Absent origin = yes, same reasoning.
61
+ def agent?(message)
62
+ message["role"].to_s == "assistant" && [nil, AGENT].include?(origin_of(message))
63
+ end
64
+
65
+ def origin_of(message)
66
+ v = message[KEY] || message[KEY.to_sym]
67
+ Coercion.presence(v)&.downcase
68
+ end
69
+
70
+ # Stamps a message hash, leaving it untouched when there is nothing to declare —
71
+ # so the common turn keeps producing exactly the two-key shape it always did.
72
+ def stamp(message, origin)
73
+ origin.nil? ? message : message.merge(KEY => origin)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # A link in the chain (stage 4). A Middleware MODIFIES the TurnState,
5
+ # short-circuits, and has operational effect (rate limit, tracing, cost) — it
6
+ # does NOT decide tool/skill permission (that is Policy). Short-circuit =
7
+ # NOT calling `nxt` and setting `state.halt_reason`. Setting halt_reason AND
8
+ # calling nxt is a contract violation (the Executor prioritizes halt_reason on
9
+ # the way back).
10
+ #
11
+ # Concurrency: it runs on the task's fiber; IO (e.g. a tracing exporter)
12
+ # must be async off the path (`Async { ... }` fire-and-forget) or accept
13
+ # the latency in the turn. No timeout of its own (covered by the turn timeout).
14
+ class Middleware
15
+ def call(state, &nxt)
16
+ nxt.call(state) # default link: pass-through
17
+ end
18
+ end
19
+
20
+ # Rack-like composition: registration order = execution order (the first is
21
+ # the outermost link). It does NOT rescue (an exception propagates as a turn
22
+ # failure) nor does it have a special halt mechanism — the short-circuit is
23
+ # structural (the link does not call nxt).
24
+ class MiddlewareStack
25
+ def initialize(middlewares = [])
26
+ @middlewares = middlewares
27
+ end
28
+
29
+ def call(state, &terminal)
30
+ chain = @middlewares.reverse.reduce(terminal) do |nxt, mw|
31
+ proc { |s| mw.call(s, &nxt) }
32
+ end
33
+ chain.call(state)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Governance of WHICH models an agent may use (`profile.model_policy`).
5
+ # Cheap allowlist: exact refs + wildcard-by-provider — evaluated against the
6
+ # RESOLVED (model, provider) at turn start (ModelResolver), so a chat override
7
+ # can never pin a model outside the agent's fence.
8
+ #
9
+ # A ref is a String in one of three shapes:
10
+ # "provider/model" -> matches that exact provider AND model
11
+ # "provider/*" -> matches ANY model of that provider (wildcard)
12
+ # "model" -> matches that model under ANY provider (provider-agnostic)
13
+ #
14
+ # `allow` semantics follow the ONE-allowlist rule of the project:
15
+ # nil = NO policy (every model allowed — parity, the common case)
16
+ # [] = NOTHING allowed (deny-all; an agent so configured cannot resolve a model)
17
+ # [refs] = the model must match at least one ref
18
+ module ModelPolicy
19
+ module_function
20
+
21
+ # policy: `profile.model_policy` — a Hash `{ "allow" => [refs] }` | nil.
22
+ # nil policy OR a nil/absent `allow` list = allowed (no fence). -> bool.
23
+ def allowed?(policy, model:, provider:)
24
+ allow = allow_list(policy)
25
+ return true if allow.nil? # no fence
26
+
27
+ model = model.to_s
28
+ provider = provider.to_s
29
+ allow.any? { |ref| match?(ref.to_s, model, provider) }
30
+ end
31
+
32
+ # Extracts the allow list (string|symbol key), nil when there is no fence.
33
+ def allow_list(policy)
34
+ return nil if policy.nil?
35
+
36
+ list = policy["allow"] || policy[:allow]
37
+ list.nil? ? nil : Array(list)
38
+ end
39
+
40
+ def match?(ref, model, provider)
41
+ if ref.include?("/")
42
+ ref_provider, ref_model = ref.split("/", 2)
43
+ return false unless ref_provider == provider
44
+
45
+ ref_model == "*" || ref_model == model
46
+ else
47
+ # provider-agnostic: match the model under any provider.
48
+ ref == model
49
+ end
50
+ end
51
+ end
52
+ end