insika 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Tool manifest: the BATCH, DATA-driven, industry-STANDARD
5
+ # (JSON Schema) form of the data-tools. A `defaults` (common binding:
6
+ # base_url/path_template/method/headers/secret_headers/response) + a list of
7
+ # `tools`. Each tool is normalized — inheriting the defaults, applying the envelope
8
+ # adapter (OpenAI/MCP/raw) and resolving `endpoint`→url + `{{secret.*}}`/
9
+ # `{{env.*}}` — into a ToolDefinition Hash consumable by the ToolStore.
10
+ #
11
+ # GENERIC: nothing here mentions a consumer/gateway — the manifest IS the contract. It
12
+ # only NORMALIZES/RESOLVES; the final validation (method/url/types, model placeholders
13
+ # `{{param}}`/turn `{{ctx.*}}`, safe subset of JSON Schema) belongs to
14
+ # ToolDefinition (single source). The batch upsert + hot reload belongs to the
15
+ # :import_tools Command (which injects the secret/env resolvers).
16
+ #
17
+ # Two placeholder classes by resolution MOMENT:
18
+ # - `{{param}}`/`{{ctx.*}}` : resolved AT THE TURN (by DataDefinedTool) —
19
+ # they stay INTACT here; ToolDefinition validates them.
20
+ # - `{{secret.*}}`/`{{env.*}}` : resolved AT INGESTION (here) — from the deployment,
21
+ # never from the manifest (R3). `{{env.*}}` is non-secret config (any
22
+ # template); `{{secret.*}}` is a credential and is ONLY allowed in a header
23
+ # declared in `secret_headers`, with value === `{{secret.X}}` (nothing literal,
24
+ # nothing outside a header — otherwise the secret would leak without masking).
25
+ #
26
+ # Interface envelope (what the model sees), by adapter:
27
+ # - `parameters` (raw JSON Schema), OR
28
+ # - `{ "function": { name, description, parameters } }` (OpenAI/Anthropic), OR
29
+ # - `{ "inputSchema": {...} }` (MCP).
30
+ ToolManifest = Data.define(:version, :defaults, :tools)
31
+
32
+ # Reopens the class (constants inside a Data.define block land in the wrong LEXICAL
33
+ # scope — same pattern as the sibling ToolDefinition).
34
+ class ToolManifest
35
+ ENV_PREFIX = "env."
36
+ SECRET_PREFIX = "secret."
37
+ private_constant :ENV_PREFIX, :SECRET_PREFIX
38
+
39
+ # Raw Hash (string|symbol keys) -> ToolManifest. Validates only the top-level
40
+ # STRUCTURE (version/defaults/tools); each tool's content is validated during
41
+ # normalization (per-tool, isolable by the Command — R4). Raises ValidationError.
42
+ def self.from_h(hash)
43
+ h = stringify(hash || {})
44
+ version = h.fetch("version", 1)
45
+ defaults = h.fetch("defaults", {}) || {}
46
+ tools = h.fetch("tools", []) || []
47
+ raise Insika::ValidationError, "manifest: 'defaults' must be an object" unless defaults.is_a?(Hash)
48
+ raise Insika::ValidationError, "manifest: 'tools' must be a list" unless tools.is_a?(Array)
49
+
50
+ new(version: version, defaults: defaults, tools: tools)
51
+ end
52
+
53
+ # -> [ { ToolDefinition hash } ] with inherited defaults, normalized envelopes,
54
+ # resolved endpoint→url and resolved secret/env. Raises on the FIRST malformed
55
+ # tool — use #definition_for per-tool to isolate partial failure (R4).
56
+ def tool_definitions(secrets:, env:)
57
+ tools.map { |t| definition_for(t, secrets: secrets, env: env) }
58
+ end
59
+
60
+ # Normalizes ONE raw tool -> ToolDefinition Hash. Raises ValidationError
61
+ # (invalid envelope, missing endpoint/url, secret/env not configured, R3).
62
+ def definition_for(raw_tool, secrets:, env:)
63
+ t = self.class.stringify(raw_tool || {})
64
+ raise Insika::ValidationError, "tool must be an object" unless t.is_a?(Hash)
65
+
66
+ interface = extract_interface(t)
67
+ binding = build_binding(t, secrets, env)
68
+
69
+ {
70
+ "name" => interface[:name], "description" => interface[:description],
71
+ "parameters" => interface[:parameters],
72
+ "request" => binding[:request],
73
+ "response" => binding[:response],
74
+ "secret_headers" => binding[:secret_headers],
75
+ "side_effect" => t["side_effect"],
76
+ "group" => t["group"] || defaults["group"], # per-group allowlist:
77
+ "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
+ # Per-tool only: "this result ends the turn" is a property of THIS backend's
79
+ # response, never something a manifest default should hand to its siblings.
80
+ "halt_when" => t["halt_when"]
81
+ }.compact
82
+ end
83
+
84
+ private
85
+
86
+ # Envelope adapter: extracts the INTERFACE (name/description/parameters) from
87
+ # any standard form. name/description fall back to the tool's top level when the
88
+ # envelope does not carry them.
89
+ def extract_interface(t)
90
+ fn = t["function"]
91
+ if fn.is_a?(Hash) # OpenAI/Anthropic function tool
92
+ { name: fn["name"] || t["name"], description: fn["description"] || t["description"],
93
+ parameters: fn["parameters"] }
94
+ elsif t.key?("inputSchema") # MCP tool
95
+ { name: t["name"], description: t["description"], parameters: t["inputSchema"] }
96
+ else # raw: parameters is JSON Schema
97
+ { name: t["name"], description: t["description"], parameters: t["parameters"] }
98
+ end
99
+ end
100
+
101
+ # Builds the binding (request/response/secret_headers) inheriting defaults, with the
102
+ # tool winning. Resolves endpoint→url, secret (only in secret headers) and env.
103
+ def build_binding(t, secrets, env)
104
+ method = (t["method"] || defaults["method"] || "GET").to_s.upcase
105
+ headers = merge_maps(defaults["headers"], t["headers"])
106
+ query = merge_maps(defaults["query"], t["query"])
107
+ body = t.key?("body") ? t["body"] : defaults["body"]
108
+ secret_headers = (Array(defaults["secret_headers"]) | Array(t["secret_headers"])).map(&:to_s)
109
+ response = t["response"] || defaults["response"] || { "extract" => "body_raw" }
110
+
111
+ url = resolve_url(t, env)
112
+ headers = resolve_headers(headers, secret_headers, secrets, env)
113
+ query = query.transform_values { |v| resolve_env(v, env) }
114
+ body = body.nil? ? nil : resolve_env(body, env)
115
+ guard_no_stray_secrets!(url: url, headers: headers, secret_headers: secret_headers, query: query, body: body)
116
+
117
+ { request: { "method" => method, "url" => url, "headers" => headers,
118
+ "query" => query, "body" => body }.compact,
119
+ response: response, secret_headers: secret_headers }
120
+ end
121
+
122
+ # explicit tool url OR base_url + path_template({endpoint}). The `endpoint` is
123
+ # DATA (never inferred from the name — R6): it resolves name↔slug remaps. `{endpoint}`
124
+ # is a manifest substitution (single key), distinct from the turn's `{{param}}`.
125
+ def resolve_url(t, env)
126
+ if self.class.presence(t["url"])
127
+ return resolve_env(t["url"], env)
128
+ end
129
+
130
+ base = self.class.presence(defaults["base_url"]) ||
131
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.base_url")
132
+ path = self.class.presence(defaults["path_template"]) ||
133
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no url and manifest has no defaults.path_template")
134
+ endpoint = self.class.presence(t["endpoint"]) ||
135
+ (raise Insika::ValidationError, "tool '#{t['name']}' has no 'endpoint' (never inferred from the name — R6)")
136
+
137
+ resolve_env(base.to_s + path.to_s.gsub("{endpoint}", endpoint.to_s), env)
138
+ end
139
+
140
+ # Resolves headers: a SECRET header (name in secret_headers) MUST contain a
141
+ # {{secret.*}} reference (never literal — R3; a prefix like "Bearer " is ok) and
142
+ # resolves secret+env; the rest resolve only {{env.*}}.
143
+ def resolve_headers(headers, secret_headers, secrets, env)
144
+ headers.each_with_object({}) do |(name, value), acc|
145
+ acc[name] =
146
+ if secret_headers.include?(name)
147
+ resolve_secret_header(name, value, secrets, env)
148
+ else
149
+ resolve_env(value, env)
150
+ end
151
+ end
152
+ end
153
+
154
+ def resolve_secret_header(name, value, secrets, env)
155
+ unless value.to_s.match?(/\{\{\s*#{SECRET_PREFIX}/)
156
+ raise Insika::ValidationError,
157
+ "secret_header '#{name}' must reference {{secret.<name>}} (never a literal — R3)"
158
+ end
159
+
160
+ substitute(value, secrets: secrets, env: env)
161
+ end
162
+
163
+ # Substitutes {{env.X}} with the deployment value; leaves the other placeholders
164
+ # ({{param}}/{{ctx.*}}/{{secret.*}}) INTACT. Used on NON-secret fields — a
165
+ # remaining {{secret.*}} is blocked by #guard_no_stray_secrets! (it would leak).
166
+ def resolve_env(template, env)
167
+ substitute(template, secrets: nil, env: env)
168
+ end
169
+
170
+ # Ingestion interpolator: always resolves {{env.X}}; resolves {{secret.X}} only
171
+ # when `secrets` was passed (secret fields). TURN placeholders
172
+ # ({{param}}/{{ctx.*}}) stay INTACT. Missing secret/env -> ValidationError.
173
+ def substitute(template, secrets:, env:)
174
+ template.to_s.gsub(Insika::ToolDefinition::PLACEHOLDER_RE) do
175
+ ref = Regexp.last_match(1)
176
+ if secrets && ref.start_with?(SECRET_PREFIX)
177
+ fetch!(secrets, ref.delete_prefix(SECRET_PREFIX), "secret")
178
+ elsif ref.start_with?(ENV_PREFIX)
179
+ fetch!(env, ref.delete_prefix(ENV_PREFIX), "env")
180
+ else
181
+ Regexp.last_match(0) # preserves {{param}}/{{ctx.*}} (and {{secret.*}} outside a secret header)
182
+ end
183
+ end
184
+ end
185
+
186
+ def fetch!(resolver, key, kind)
187
+ val = resolver[key]
188
+ raise Insika::ValidationError, "#{kind} '#{key}' not configured in the deployment" if blank?(val)
189
+
190
+ val.to_s
191
+ end
192
+
193
+ # A secret outside a secret-header would leak (it is not masked): reject {{secret.*}}
194
+ # in url/query/body/non-secret header after env resolution.
195
+ def guard_no_stray_secrets!(url:, headers:, secret_headers:, query:, body:)
196
+ non_secret = headers.reject { |name, _| secret_headers.include?(name) }.values
197
+ strings = [url, *non_secret, *query.values, body].compact
198
+ stray = strings.flat_map { |s| s.to_s.scan(Insika::ToolDefinition::PLACEHOLDER_RE).flatten }
199
+ .select { |r| r.start_with?(SECRET_PREFIX) }.uniq
200
+ return if stray.empty?
201
+
202
+ raise Insika::ValidationError,
203
+ "{{secret.*}} is only allowed in secret_headers (would leak without masking): #{stray.join(', ')}"
204
+ end
205
+
206
+ def merge_maps(base, override)
207
+ (self.class.stringify(base || {})).merge(self.class.stringify(override || {}))
208
+ end
209
+
210
+ def blank?(v) = Insika::Coercion.blank?(v)
211
+
212
+ # Deep string keys (the wire may arrive symbolized; the nested JSON Schema
213
+ # must stay string-keyed — same as ToolDefinition's canonicalization).
214
+ def self.stringify(obj) = Insika::Coercion.deep_stringify(obj)
215
+
216
+ def self.presence(str) = Insika::Coercion.presence(str)
217
+ end
218
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Insika
6
+ # Mechanical (no-LLM) compression of tool output in the replayed transcript
7
+ # (A3/C3, hermes phase 1): identical tool results are stored ONCE — the first
8
+ # occurrence full — and every byte-identical repeat is replaced with a compact
9
+ # back-reference to it plus the original's first line. The cheap half of
10
+ # compaction: a search tool that returns the same catalog page over N turns
11
+ # currently pays for the full body N times, and that accumulation is what
12
+ # blows the budget first.
13
+ #
14
+ # Deliberately crude on purpose: SHA-256 digest, so ONLY exact matches dedupe —
15
+ # a differing detail is kept, never "close enough" dropped. Deterministic per
16
+ # input (the same transcript compresses identically every turn, prompt-cache
17
+ # friendly). Never touches user/assistant content and never mutates the input
18
+ # (symbol or string keys are preserved per message).
19
+ #
20
+ # Consumer: the history path of Context::Providers::Session, opt-in via
21
+ # AgentProfile#tool_output_compression. The LLM half of compaction (real
22
+ # summarization) remains F5 — decided with data, not by matrix.
23
+ class ToolOutputCompressor
24
+ # Below this a back-reference can never cost less than the body it
25
+ # replaces (an early bail; the REAL gate is the length check per repeat).
26
+ MIN_LENGTH = 200
27
+ # The mechanical "1-line summary": the original's first line, capped here
28
+ # (160 chars ≈ ~40 tokens — a bounded, informative stub).
29
+ SUMMARY_LIMIT = 160
30
+
31
+ # The boilerplate deliberately carries NO positional pointer ("see above"):
32
+ # the budget cut evicts the OLDEST unit first, which is the original this
33
+ # back-reference points at — a "↑" that survives its target leaves a dangle
34
+ # in the model's context (C3). The summary is the content; it stands alone.
35
+ BOILERPLATE = "[repeated tool output — byte-identical to an earlier result " \
36
+ "in this conversation; One-line summary: "
37
+
38
+ class << self
39
+ # [message, ...] with repeated tool results back-referenced. A non-tool
40
+ # message or a tool result that is not a String passes through untouched.
41
+ # A repeat is replaced ONLY when the back-reference is strictly shorter
42
+ # than the content it would replace (in the 200–~260-char band a
43
+ # back-reference is LONGER than the original — replacing there would
44
+ # GROW the transcript, C3).
45
+ def compress_transcript(messages)
46
+ seen = {}
47
+ Array(messages).map do |m|
48
+ content = text_of(m)
49
+ next m unless content && content.length >= MIN_LENGTH
50
+
51
+ digest = Digest::SHA256.hexdigest(content)
52
+ if seen.key?(digest)
53
+ replacement = back_reference(seen[digest][:summary])
54
+ replacement.length < content.length ? replace_content(m, replacement) : m
55
+ else
56
+ seen[digest] = { summary: summarize(content) }
57
+ m
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # -> String | nil: the content of a role:tool message, nil otherwise
65
+ # (compression is for tool RESULTS only; a person's words are never touched).
66
+ def text_of(m)
67
+ return nil unless m.is_a?(Hash)
68
+ return nil unless (m[:role] || m["role"]).to_s == "tool"
69
+
70
+ c = m[:content] || m["content"]
71
+ c.is_a?(String) ? c : nil
72
+ end
73
+
74
+ def back_reference(summary)
75
+ "#{BOILERPLATE}#{summary}]"
76
+ end
77
+
78
+ # A dup with the content replaced under the SAME key style as the original
79
+ # (symbol-keyed message -> symbol content, string-keyed -> string content).
80
+ def replace_content(m, text)
81
+ out = m.dup
82
+ if m.key?(:content)
83
+ out[:content] = text
84
+ else
85
+ out["content"] = text
86
+ end
87
+ out
88
+ end
89
+
90
+ # The mechanical one-line summary: the first line, whitespace-collapsed and
91
+ # capped. For a multi-line JSON tool body the first line is a real head; for
92
+ # a single-line blob it is a truncated head — never the whole body.
93
+ def summarize(content)
94
+ first = content.to_s.split(/\r?\n/).first.to_s
95
+ collapsed = first.gsub(/\s+/, " ").strip
96
+ collapsed.length <= SUMMARY_LIMIT ? collapsed : "#{collapsed[0, SUMMARY_LIMIT]}…"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Tool registry. Inherits from the generic one; `optional`/`side_effect`
5
+ # live in the metadata (defaults false). `side_effect: true` marks a
6
+ # non-idempotent tool — the checkpoint mechanism consumes it via `side_effect?`.
7
+ class ToolRegistry < Registry
8
+ # Legacy compat: `optional:`/`plugin:` are still accepted; `side_effect:` is
9
+ # new. Everything is normalized into the metadata (defaults false).
10
+ def register(name, callable = nil, optional: false, side_effect: false, plugin: nil, **extra, &block)
11
+ super(name, callable, plugin: plugin,
12
+ optional: optional, side_effect: side_effect, **extra, &block)
13
+ end
14
+
15
+ # -> bool; consumed by ToolEnvelope. Unknown tool = false.
16
+ def side_effect?(name)
17
+ entry = entries.find { |e| e.name == name.to_s }
18
+ entry ? !!entry.metadata[:side_effect] : false
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # DATA-DEFINED tools authored at runtime. One record per tool in the ConfigStore
7
+ # (scope "tools"), keyed by name. It stores the whole ToolDefinition, versions it
8
+ # (like SkillStore/AgentFileStore) and MASKS the credential headers (just as
9
+ # McpStore masks the env): headers whose name is in `secret_headers` never
10
+ # leave in plaintext to the UI — they become the sentinel `__OCULTO__`. Only `get_raw`/
11
+ # `all_raw` (consumed by DataDefinedTool/overlay, never the screen) return the
12
+ # real values.,.
13
+ #
14
+ # Record in the ConfigStore:
15
+ # { "definition" => { ...ToolDefinition#to_h... },
16
+ # "updated_at" => iso8601,
17
+ # "history" => [ { "definition" =>, "at" => }, ... ] }
18
+ class ToolStore
19
+ SCOPE = "tools"
20
+ HISTORY_MAX = 20
21
+
22
+ def initialize(config_store:)
23
+ @cs = config_store
24
+ end
25
+
26
+ # -> MASKED ToolDefinition Hash (secret headers with sentinel) | nil.
27
+ def get(name)
28
+ d = raw_definition(name)
29
+ d && mask_definition(d)
30
+ end
31
+
32
+ # -> ToolDefinition Hash with REAL headers | nil. Internal use (tool/overlay).
33
+ def get_raw(name) = raw_definition(name)
34
+
35
+ # -> [String] names, lexicographic order.
36
+ def names = @cs.keys(SCOPE)
37
+
38
+ # -> [Hash] MASKED definitions (for the UI).
39
+ def all
40
+ names.filter_map { |n| get(n) }
41
+ end
42
+
43
+ # -> [Hash] definitions with REAL headers (for the overlay/registry). Never to the screen.
44
+ def all_raw
45
+ names.filter_map { |n| raw_definition(n) }
46
+ end
47
+
48
+ # Writes (upsert). `attrs` is a ToolDefinition Hash (the UI may send secret
49
+ # headers as sentinel: preserves what was stored). create_only refuses to overwrite.
50
+ # Validates via ToolDefinition.from_h. -> MASKED definition Hash.
51
+ def write(attrs, create_only: false)
52
+ definition = Insika::ToolDefinition.from_h(attrs) # validates (raises ValidationError)
53
+ name = definition.name
54
+ existing = @cs.get(SCOPE, name)
55
+ raise Insika::ValidationError, "tool '#{name}' already exists" if create_only && existing
56
+
57
+ final = definition.to_h
58
+ final["request"]["headers"] = reconcile_secret_headers(
59
+ final["request"]["headers"], definition.secret_headers,
60
+ existing&.dig("definition", "request", "headers")
61
+ )
62
+
63
+ rec = build_record(final, existing)
64
+ @cs.put(SCOPE, name, rec)
65
+ mask_definition(final)
66
+ end
67
+
68
+ # -> bool (did it exist?).
69
+ def delete(name) = @cs.delete(SCOPE, name.to_s)
70
+
71
+ # -> [ { "definition" => MASKED, "at" => } ] most recent first.
72
+ def versions(name)
73
+ (record(name)&.fetch("history", []) || []).map do |h|
74
+ { "definition" => mask_definition(h["definition"]), "at" => h["at"] }
75
+ end
76
+ end
77
+
78
+ # Restores version `index` as the current definition (new write, versions the current).
79
+ # -> MASKED definition Hash.
80
+ def restore(name, index)
81
+ rec = record(name)
82
+ raise Insika::NotFoundError, "tool '#{name}' not found" unless rec
83
+
84
+ hist = rec.fetch("history", [])
85
+ i = Integer(index)
86
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
87
+
88
+ # The historical version stores REAL headers -> rewrite directly (bypasses the
89
+ # input masking; write only re-validates the structure).
90
+ write(hist[i]["definition"])
91
+ end
92
+
93
+ private
94
+
95
+ def record(name) = @cs.get(SCOPE, name.to_s)
96
+
97
+ def raw_definition(name) = record(name)&.fetch("definition", nil)
98
+
99
+ def build_record(definition, current)
100
+ history = current ? current.fetch("history", []) : []
101
+ if current
102
+ history = [{ "definition" => current["definition"], "at" => current["updated_at"] }] + history
103
+ history = history.first(HISTORY_MAX)
104
+ end
105
+ { "definition" => definition, "updated_at" => Time.now.utc.iso8601, "history" => history }
106
+ end
107
+
108
+ # Each header in secret_headers becomes a sentinel; the rest pass through intact.
109
+ def mask_definition(definition)
110
+ secret = definition["secret_headers"] || []
111
+ return definition if secret.empty?
112
+
113
+ headers = (definition.dig("request", "headers") || {}).each_with_object({}) do |(k, v), acc|
114
+ acc[k] = secret.include?(k) ? SecretMasking.mask(v) : v
115
+ end
116
+ definition.merge("request" => definition["request"].merge("headers" => headers))
117
+ end
118
+
119
+ # Reconciles ONLY the secret headers against what was stored: sentinel preserves,
120
+ # a new string replaces, "" clears (removes the key). Non-secret pass through intact.
121
+ def reconcile_secret_headers(incoming, secret_names, existing)
122
+ old = existing || {}
123
+ secret_names.each_with_object(incoming.dup) do |hname, acc|
124
+ next unless acc.key?(hname)
125
+
126
+ value = SecretMasking.reconcile(acc[hname], old[hname])
127
+ if value.nil? || value.to_s.empty?
128
+ acc.delete(hname)
129
+ else
130
+ acc[hname] = value
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # Per-session TOOL-CALL trace, for debugging in the Studio. One
7
+ # record per session in the raw backend (scope "tool_traces") — RUNTIME data, next
8
+ # to sessions/tasks, NOT config (hence the raw `store:`, like SessionStore,
9
+ # not the ConfigStore). A capped LIST of entries; ToolEnvelope writes one
10
+ # per call (name + model args + result + status + ms). Serves
11
+ # `/studio/sessions/:id`.
12
+ #
13
+ # SECURITY lives HERE (the store owns it): the screen is for the operator, but the
14
+ # args/response may carry PII/credentials. Every write goes through mask (values of
15
+ # sensitive keys become a sentinel) + clip (truncates large fields). The ephemeral
16
+ # `data_tool_call` event (name+status, 0 leakage) still goes to the live cards;
17
+ # THIS is the durable, richer record, behind the Studio login.
18
+ class ToolTraceStore
19
+ SCOPE = "tool_traces"
20
+ MAX_PER_SESSION = 200 # tail per session (avoids growing without bound)
21
+ MAX_FIELD_CHARS = 2_000 # cap per args/result field
22
+ SECRET_KEY_RE = /token|secret|authorization|password|passwd|api[-_]?key|bearer|cookie/i
23
+
24
+ def initialize(store:)
25
+ @store = store
26
+ end
27
+
28
+ # Writes a (sanitized) entry into the session. Missing session_id -> no-op.
29
+ # ToolEnvelope already guards, but so does this: the trace NEVER breaks the turn.
30
+ def record(session_id:, entry:)
31
+ sid = session_id.to_s
32
+ return if sid.empty?
33
+
34
+ list = (@store.get(SCOPE, sid) || []) + [sanitize(entry)]
35
+ @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # -> [Hash] session entries in chronological order. [] if none.
41
+ def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
42
+
43
+ # Discards a session's trace (cleanup). -> bool (did it exist?).
44
+ def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
45
+
46
+ private
47
+
48
+ def sanitize(entry)
49
+ e = stringify_keys(entry)
50
+ {
51
+ "turn" => e["turn"], "tool" => e["tool"].to_s, "call_id" => e["call_id"].to_s,
52
+ "ok" => ok?(e["result"]),
53
+ "args" => clip(mask(e["args"])), "result" => clip(mask(e["result"])),
54
+ "ms" => e["ms"], "at" => e["at"].to_s
55
+ }
56
+ end
57
+
58
+ # Conventional tool error = Hash with key "error"/:error (everything else is ok).
59
+ def ok?(result)
60
+ !(result.is_a?(Hash) && (result.key?("error") || result.key?(:error)))
61
+ end
62
+
63
+ # Masks values of sensitive keys (recursive); the rest pass through intact.
64
+ def mask(obj)
65
+ case obj
66
+ when Hash
67
+ obj.each_with_object({}) do |(k, v), acc|
68
+ acc[k.to_s] = SECRET_KEY_RE.match?(k.to_s) ? SecretMasking::SENTINEL : mask(v)
69
+ end
70
+ when Array then obj.map { |v| mask(v) }
71
+ else obj
72
+ end
73
+ end
74
+
75
+ # -> Readable, capped String (JSON when structured). Truncates by CHARACTER
76
+ # (not byte — avoids cutting UTF-8 mid-sequence).
77
+ def clip(obj)
78
+ s = obj.is_a?(String) ? obj : safe_json(obj)
79
+ s.length > MAX_FIELD_CHARS ? "#{s[0, MAX_FIELD_CHARS]}…(truncado)" : s
80
+ end
81
+
82
+ def safe_json(obj)
83
+ JSON.generate(obj)
84
+ rescue StandardError
85
+ obj.inspect
86
+ end
87
+
88
+ def stringify_keys(hash)
89
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # Delegates to a remote A2A agent. A NORMAL Tool Registry tool — the
8
+ # agent's allowlist governs who may delegate. Lazy require of the gem: it does NOT
9
+ # enter lib/insika.rb; it is loaded in the registration block (wiring) on the 1st
10
+ # instance. One instance per remote agent (its own name/description).
11
+ class A2ARemote < RubyLLM::Tool
12
+ param :message, desc: "The message/task for the remote agent"
13
+
14
+ def initialize(client:, url:, tool_name:, description:, event_stream: nil)
15
+ @client = client
16
+ @url = url
17
+ @tool_name = tool_name
18
+ @description = description
19
+ @event_stream = event_stream
20
+ super()
21
+ end
22
+
23
+ # name/description per INSTANCE (RubyLLM derives them from the class; we override —
24
+ # otherwise the model would see "insika--tools--a2a_remote" for every remote).
25
+ def name = @tool_name
26
+ def description = @description
27
+
28
+ def execute(message:)
29
+ result = @client.call(@url, message.to_s)
30
+ emit(result)
31
+ result[:error] ? { error: result[:error] } : result[:text]
32
+ end
33
+
34
+ private
35
+
36
+ # :a2a_call without task correlation: registry tools do not receive the
37
+ # TurnState -> meta {} (like the Builder's :provider_warning). The :tool_call
38
+ # from wire_callbacks already correlates the call itself.
39
+ def emit(result)
40
+ @event_stream&.emit(Insika::Event.new(
41
+ type: :a2a_call,
42
+ data: { agent: @tool_name, remote_task_id: result[:id], state: result[:state] },
43
+ meta: {}
44
+ ))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Tools
5
+ # Names the parent's subagent allowlist INSIDE the delegation tools' schema.
6
+ #
7
+ # The allowlist is per-turn data (`profile.subagents`), while a RubyLLM tool's
8
+ # schema is class-level, so the enum has to be injected per instance. Without
9
+ # it the model is told only that `agent` "must be one this agent may spawn" and
10
+ # is left to guess the ids; with it, the valid values are part of the contract
11
+ # the provider sees.
12
+ #
13
+ # Tolerant by construction: JSON-schema hashes reach us with symbol keys (the
14
+ # class-level `params(...)` form) or string keys (anything that round-tripped
15
+ # through JSON), and a shape we do not recognize is returned UNTOUCHED — a
16
+ # schema we failed to annotate still works, one we corrupted would break every
17
+ # delegation.
18
+ module AgentEnum
19
+ module_function
20
+
21
+ # schema: the tool's JSON schema · allowed: [String] · path: the property
22
+ # chain leading to the `agent` property, e.g. %i[agent] or %i[tasks agent].
23
+ # -> a COPY with `enum` set, or the original when there is nothing to do.
24
+ def inject(schema, allowed, path:)
25
+ return schema if schema.nil? || Array(allowed).empty?
26
+
27
+ copy = deep_dup(schema)
28
+ target = resolve(copy, path)
29
+ return schema if target.nil?
30
+
31
+ target[key_for(target, :enum)] = Array(allowed).map(&:to_s)
32
+ copy
33
+ end
34
+
35
+ # Walks `properties` (and `items` for an array) down to the named property.
36
+ def resolve(node, path)
37
+ path.reduce(node) do |current, segment|
38
+ props = fetch(current, :properties)
39
+ props = fetch(fetch(current, :items) || {}, :properties) if props.nil?
40
+ prop = props && (props[segment] || props[segment.to_s])
41
+ return nil unless prop.is_a?(Hash)
42
+
43
+ prop
44
+ end
45
+ end
46
+
47
+ def fetch(node, key)
48
+ return nil unless node.is_a?(Hash)
49
+
50
+ node[key] || node[key.to_s]
51
+ end
52
+
53
+ # Keep the hash's own key convention instead of mixing symbols into a
54
+ # string-keyed schema.
55
+ def key_for(hash, key)
56
+ hash.keys.any? { |k| k.is_a?(String) } ? key.to_s : key
57
+ end
58
+
59
+ def deep_dup(value)
60
+ case value
61
+ when Hash then value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
62
+ when Array then value.map { |v| deep_dup(v) }
63
+ else value
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end