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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# TOLERANT frontmatter parser (the YAML `--- ... ---` block of a SKILL.md).
|
|
7
|
+
# The convention is YAML, but real packs carry PROSE in `description` — with `: `
|
|
8
|
+
# (colon + space), quotes, parentheses — which STRICT YAML rejects
|
|
9
|
+
# ("mapping values are not allowed in this context"). The OpenClaw gateway
|
|
10
|
+
# tolerates it; the insika has to tolerate it too (the same pack must hold).
|
|
11
|
+
#
|
|
12
|
+
# Strategy: try YAML (respects quoted / multi-line / lists); if the YAML
|
|
13
|
+
# fails OR doesn't yield a Hash, fall back to a LINE-BY-LINE parse that splits on
|
|
14
|
+
# the FIRST `:` and treats the rest as a raw string — recovers name/description
|
|
15
|
+
# even with `: ` in the middle of the value. -> Hash with String keys. NEVER raises.
|
|
16
|
+
module Frontmatter
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def parse(text)
|
|
20
|
+
loaded = begin
|
|
21
|
+
YAML.safe_load(text.to_s)
|
|
22
|
+
rescue Psych::SyntaxError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
loaded.is_a?(Hash) ? stringify(loaded) : lenient(text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Split on the first `:` of each line; value = the rest (string). A line
|
|
29
|
+
# without `:` is ignored. Preserves `: ` internal to the value (the case that breaks YAML).
|
|
30
|
+
def lenient(text)
|
|
31
|
+
text.to_s.each_line.each_with_object({}) do |line, acc|
|
|
32
|
+
next unless line.include?(":")
|
|
33
|
+
|
|
34
|
+
key, _, value = line.partition(":")
|
|
35
|
+
k = key.strip
|
|
36
|
+
acc[k] = value.strip unless k.empty?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stringify(hash) = hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# AUTHORED eval cases (promoted to a store by).
|
|
9
|
+
#
|
|
10
|
+
# A golden case used to be a YAML file in `evals/golden/`, which means only someone
|
|
11
|
+
# with a checkout and a text editor could add one. The rubric is the part of an eval
|
|
12
|
+
# a domain owner can actually write ("consults the coupon by tool and does NOT invent
|
|
13
|
+
# a code") — so it has to be authorable where they already work, the Studio.
|
|
14
|
+
#
|
|
15
|
+
# One record per case in the ConfigStore (scope "goldens"):
|
|
16
|
+
# { "case" => { "id" =>, "agent" =>, "turns" => [...], "expect" => {...} },
|
|
17
|
+
# "updated_at" => iso8601 }
|
|
18
|
+
#
|
|
19
|
+
# The stored shape is the SAME mapping the YAML file holds, and every write is
|
|
20
|
+
# validated by `Evals::GoldenLoader.build` — the one validator, so a case authored in
|
|
21
|
+
# the Studio and a case read from disk cannot diverge. `evals/golden/**` stays the
|
|
22
|
+
# export format and the seed for a fresh deploy (`insika evals:import`), for the same
|
|
23
|
+
# reason the knowledge RFC keeps markdown as both: no converter to keep honest.
|
|
24
|
+
#
|
|
25
|
+
# No version history here, unlike the prompt/skill stores: the corpus on disk IS the
|
|
26
|
+
# backup, and re-importing restores it.
|
|
27
|
+
class GoldenStore
|
|
28
|
+
SCOPE = "goldens"
|
|
29
|
+
|
|
30
|
+
def initialize(config_store:)
|
|
31
|
+
@cs = config_store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Upsert. `raw` is the case mapping (string or symbol keys), validated before it
|
|
35
|
+
# lands. -> Evals::Golden. InvalidGolden if malformed — a silently dropped case is
|
|
36
|
+
# a hole in the safety net, which is why the loader raises instead of skipping.
|
|
37
|
+
def write(raw, path: nil)
|
|
38
|
+
golden = Evals::GoldenLoader.build(Coercion.deep_stringify(raw), source: "(store)")
|
|
39
|
+
record = { "case" => case_hash(golden), "updated_at" => timestamp }
|
|
40
|
+
# Remember where it came from, so an export reproduces the corpus LAYOUT instead
|
|
41
|
+
# of renaming every file. The curated corpus groups the safety suite by PURPOSE
|
|
42
|
+
# (`safety/`) while its cases belong to an example agent — deriving the path from
|
|
43
|
+
# the agent would scatter them. An edit keeps whatever path it already had.
|
|
44
|
+
record["path"] = Coercion.presence(path) || @cs.get(SCOPE, golden.id)&.fetch("path", nil)
|
|
45
|
+
@cs.put(SCOPE, golden.id, record.compact)
|
|
46
|
+
golden
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# -> Evals::Golden | nil. A stored case that no longer validates surfaces as nil
|
|
50
|
+
# rather than raising here; `insika doctor`-style sweeps are the place to shout.
|
|
51
|
+
def find(id)
|
|
52
|
+
record = @cs.get(SCOPE, id.to_s)
|
|
53
|
+
record && build(record)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# -> [String] case ids, lexicographic (a stable run order, like the file loader's
|
|
57
|
+
# sort by path).
|
|
58
|
+
def ids = @cs.keys(SCOPE)
|
|
59
|
+
|
|
60
|
+
# -> [Evals::Golden] every valid case, in id order.
|
|
61
|
+
def all = ids.filter_map { |id| find(id) }
|
|
62
|
+
|
|
63
|
+
# -> [Evals::Golden] one agent's cases.
|
|
64
|
+
def for_agent(agent_id) = all.select { |g| g.agent == agent_id.to_s }
|
|
65
|
+
|
|
66
|
+
# -> [String] ids whose stored mapping no longer validates (an edit that broke the
|
|
67
|
+
# shape). The Studio shows these; they never silently vanish from a run.
|
|
68
|
+
def invalid
|
|
69
|
+
ids.reject { |id| find(id) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -> bool (did it exist?)
|
|
73
|
+
def delete(id) = @cs.delete(SCOPE, id.to_s)
|
|
74
|
+
|
|
75
|
+
# Bulk import from the corpus on disk. Returns the ids written. `overwrite: false`
|
|
76
|
+
# keeps an already-authored case (the store wins over the seed, same rule the
|
|
77
|
+
# SkillCatalog overlay uses).
|
|
78
|
+
def import_dir(dir, overwrite: true)
|
|
79
|
+
root = File.expand_path(dir)
|
|
80
|
+
Evals::GoldenLoader.load_dir(dir).filter_map do |golden|
|
|
81
|
+
next if !overwrite && @cs.get(SCOPE, golden.id)
|
|
82
|
+
|
|
83
|
+
# Expand both sides: the loader's `source` is whatever shape `dir` was given in
|
|
84
|
+
# (a relative glob stays relative), so comparing raw strings would leave the
|
|
85
|
+
# corpus prefix inside the stored path.
|
|
86
|
+
relative = File.expand_path(golden.source.to_s).delete_prefix("#{root}/")
|
|
87
|
+
write(case_hash(golden), path: relative).id
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The export format IS the import format, written at the path the case came from
|
|
92
|
+
# (falling back to `<agent>/<id>.yml` for a case authored in the Studio). -> [paths]
|
|
93
|
+
#
|
|
94
|
+
# It is NOT a faithful copy of the curated corpus: `YAML.dump` drops the comments
|
|
95
|
+
# those files carry, and each one explains what its case is for. So exporting over
|
|
96
|
+
# an existing corpus demands `force` — losing that prose silently would be a bad
|
|
97
|
+
# trade for a convenience.
|
|
98
|
+
def export_dir(dir, force: false)
|
|
99
|
+
existing = Dir.glob(File.join(dir, "**", "*.{yml,yaml}"))
|
|
100
|
+
if existing.any? && !force
|
|
101
|
+
raise Insika::ValidationError,
|
|
102
|
+
"#{dir} already holds #{existing.size} case file(s); exporting rewrites them and " \
|
|
103
|
+
"DROPS their comments — pass --force to accept that, or export somewhere else"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
all.map do |golden|
|
|
107
|
+
path = File.join(dir, path_of(golden))
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
109
|
+
File.write(path, YAML.dump(case_hash(golden)))
|
|
110
|
+
path
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def path_of(golden)
|
|
117
|
+
Coercion.presence(@cs.get(SCOPE, golden.id)&.fetch("path", nil)) ||
|
|
118
|
+
File.join(golden.agent, "#{golden.id}.yml")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build(record)
|
|
122
|
+
Evals::GoldenLoader.build(record["case"] || {}, source: "(store)")
|
|
123
|
+
rescue Evals::GoldenLoader::InvalidGolden
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Golden -> the plain mapping (what YAML holds and what the store persists).
|
|
128
|
+
# `source` is a load-time detail, never part of the case.
|
|
129
|
+
# `requires` is omitted when empty so a case that runs everywhere stays as short
|
|
130
|
+
# in the store as it is on disk — but it is NEVER dropped when present: a case
|
|
131
|
+
# that silently lost its requirements would come back as a failure on every
|
|
132
|
+
# deployment that lacks the tool, which is the exact lie `requires` exists to end.
|
|
133
|
+
def case_hash(golden)
|
|
134
|
+
h = { "id" => golden.id, "agent" => golden.agent, "turns" => golden.turns }
|
|
135
|
+
h["requires"] = golden.requires unless golden.requires.empty?
|
|
136
|
+
# Same rule for `reference`: omitted when absent, never dropped
|
|
137
|
+
# when present. A case that lost its reference in a round-trip would stop being
|
|
138
|
+
# compared against the incumbent and the report would look identical.
|
|
139
|
+
h["reference"] = golden.reference unless golden.reference.empty?
|
|
140
|
+
h.merge("expect" => golden.expect)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def timestamp = Time.now.utc.iso8601
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/insika/hooks.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Hooks ALTER the input/output of the ONE stage they wrap: Middleware
|
|
5
|
+
# modifies, Hooks alter, Events observe. They don't create their own flow nor skip
|
|
6
|
+
# stages. Synchronous and without rescue — the error->state mapping belongs to the Executor.
|
|
7
|
+
class Hooks
|
|
8
|
+
PAIRS = %i[task prompt agent tool].freeze
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@before = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@after = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# callables; multiple per pair. Registration order is significant.
|
|
16
|
+
def register(pair, before: nil, after: nil)
|
|
17
|
+
raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
|
|
18
|
+
|
|
19
|
+
@before[pair] << before if before
|
|
20
|
+
@after[pair] << after if after
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# befores in registration order (may ALTER the subject by returning the new one),
|
|
25
|
+
# yield(subject), afters in REVERSE order (may alter the result). With no
|
|
26
|
+
# registrations -> degenerates into yield(subject) (no-op). A hook that doesn't alter
|
|
27
|
+
# returns what it received; returning nil IS altering to nil (no special case).
|
|
28
|
+
def around(pair, subject)
|
|
29
|
+
run_after(pair, yield(run_before(pair, subject)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Public halves of around. Needed for the :tool pair, whose
|
|
33
|
+
# stage "body" is RubyLLM's inner loop — there is no block
|
|
34
|
+
# to wrap; the halves are called from the before_tool_call/
|
|
35
|
+
# after_tool_result callbacks separately.
|
|
36
|
+
def run_before(pair, subject)
|
|
37
|
+
raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
|
|
38
|
+
|
|
39
|
+
@before[pair].reduce(subject) { |subj, hook| hook.call(subj) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_after(pair, result)
|
|
43
|
+
raise ArgumentError, "unknown hook pair: #{pair.inspect}" unless PAIRS.include?(pair)
|
|
44
|
+
|
|
45
|
+
@after[pair].reverse.reduce(result) { |res, hook| hook.call(res) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "coercion"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Default HTTP client for data-tools. Net::HTTP (stdlib, zero-dep — spec)
|
|
9
|
+
# with its own socket timeouts (mitigates reactor blocking even if the
|
|
10
|
+
# envelope timer doesn't fire) and a response-size CAP via streaming (
|
|
11
|
+
# avoids OOM). It is INJECTABLE: tests pass a double (none hit the network);
|
|
12
|
+
# can swap in async-http without touching DataDefinedTool.
|
|
13
|
+
#
|
|
14
|
+
# Contract: request(method:, url:, headers:, body:, timeout:) -> { status:, body: }
|
|
15
|
+
# (+ `location:` on a 3xx). It does NOT follow redirects: the destination is
|
|
16
|
+
# cleared by the EgressGuard in the CALLER (DataDefinedTool), before the call —
|
|
17
|
+
# a hop taken in here would carry the model's tool call to a host nobody
|
|
18
|
+
# allowed (an SSRF around the guard). A data-tool whose API moved gets a
|
|
19
|
+
# 3xx surfaced as an error naming the new URL, and the fix is to author the
|
|
20
|
+
# final URL in the definition.
|
|
21
|
+
class HttpClient
|
|
22
|
+
DEFAULT_TIMEOUT = 30
|
|
23
|
+
MAX_BYTES = 1_000_000 # 1 MB
|
|
24
|
+
|
|
25
|
+
class ResponseTooLarge < Insika::Error; end
|
|
26
|
+
|
|
27
|
+
def initialize(max_bytes: MAX_BYTES)
|
|
28
|
+
@max_bytes = max_bytes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request(method:, url:, headers: {}, body: nil, timeout: nil)
|
|
32
|
+
uri = URI.parse(url)
|
|
33
|
+
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
34
|
+
headers.each { |k, v| req[k] = v }
|
|
35
|
+
req.body = body if body && !body.to_s.empty?
|
|
36
|
+
|
|
37
|
+
t = timeout || DEFAULT_TIMEOUT
|
|
38
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: t, read_timeout: t }
|
|
39
|
+
Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
40
|
+
result = nil
|
|
41
|
+
http.request(req) do |resp|
|
|
42
|
+
# Accumulate in BINARY: Net::HTTP yields ASCII-8BIT chunks and a
|
|
43
|
+
# multi-byte character can straddle two of them, so only byte
|
|
44
|
+
# concatenation is safe here. (A `+""` buffer would also SILENTLY turn
|
|
45
|
+
# BINARY the first time a chunk carried a non-ASCII byte.)
|
|
46
|
+
collected = +"".b
|
|
47
|
+
resp.read_body do |chunk|
|
|
48
|
+
collected << chunk
|
|
49
|
+
raise ResponseTooLarge, "response exceeds #{@max_bytes} bytes" if collected.bytesize > @max_bytes
|
|
50
|
+
end
|
|
51
|
+
# One tag at the end, over whole bytes: the body leaves here as valid
|
|
52
|
+
# UTF-8 (see Coercion.utf8) because it goes on to be a tool result —
|
|
53
|
+
# transcript, event, SSE frame — and JSON.generate rejects anything else.
|
|
54
|
+
result = { status: resp.code.to_i, body: Coercion.utf8(collected) }
|
|
55
|
+
# The redirect TARGET, so a moved API is reported as "moved to <url>"
|
|
56
|
+
# instead of a bare 3xx with the empty body servers send with it.
|
|
57
|
+
result[:location] = resp["location"].to_s if resp["location"]
|
|
58
|
+
end
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Short-lived memory of inbound event ids, so a platform's retry does not become
|
|
7
|
+
# a second LLM turn and a second reply.
|
|
8
|
+
#
|
|
9
|
+
# Every messaging platform retries a webhook it did not see acked in time, and a
|
|
10
|
+
# relay consumer that hands us its own queue does the same. Without this, the
|
|
11
|
+
# cost of one flaky ack is a duplicated turn (paid for) and a duplicated answer
|
|
12
|
+
# (visible to the customer). With it, the retry finds the id and gets the SAME
|
|
13
|
+
# task back — the caller learns it already sent this, which is a different fact
|
|
14
|
+
# from "your message was merged into someone else's turn".
|
|
15
|
+
#
|
|
16
|
+
# The id is the CALLER's (`wamid.…`, a Slack event id, whatever the consumer's
|
|
17
|
+
# queue uses). A caller that cannot supply a stable one gets at-least-once turns
|
|
18
|
+
# and is told so in the docs — the engine does not hash the content and call
|
|
19
|
+
# that dedup, because two customers legitimately typing "oi" one second apart is
|
|
20
|
+
# not a duplicate.
|
|
21
|
+
#
|
|
22
|
+
# TTL, not forever: this is a retry window, not an audit log. An expired entry is
|
|
23
|
+
# deleted lazily on read; `sweep` (boot) clears whatever nobody read back.
|
|
24
|
+
class InboundLog
|
|
25
|
+
SCOPE = "inbound"
|
|
26
|
+
KEY_PREFIX = "inbound:"
|
|
27
|
+
|
|
28
|
+
DEFAULT_TTL = 86_400 # 24h — longer than any platform's retry schedule
|
|
29
|
+
|
|
30
|
+
def initialize(store:, ttl: DEFAULT_TTL, clock: -> { Time.now.utc })
|
|
31
|
+
@store = store
|
|
32
|
+
@ttl = ttl.to_i
|
|
33
|
+
@clock = clock
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -> the task id this event already produced, or nil (never seen, or the
|
|
37
|
+
# window closed). An expired entry is removed as it is read: the next
|
|
38
|
+
# identical id is honestly a new message by then.
|
|
39
|
+
def find(key)
|
|
40
|
+
record = @store.get(SCOPE, key_for(key))
|
|
41
|
+
return nil if record.nil?
|
|
42
|
+
|
|
43
|
+
if expired?(record)
|
|
44
|
+
@store.delete(SCOPE, key_for(key))
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
record["task_id"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Remembers that `key` produced `task_id`. Last write wins, like every other
|
|
51
|
+
# store — a re-record inside the window just refreshes the expiry.
|
|
52
|
+
def record(key, task_id)
|
|
53
|
+
@store.set(SCOPE, key_for(key), {
|
|
54
|
+
"key" => key.to_s,
|
|
55
|
+
"task_id" => task_id&.to_s,
|
|
56
|
+
"expires_at" => (@clock.call + @ttl).iso8601
|
|
57
|
+
})
|
|
58
|
+
task_id
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Boot housekeeping: drops the entries nobody came back for. -> count removed.
|
|
62
|
+
def sweep
|
|
63
|
+
@store.list(SCOPE, KEY_PREFIX).count do |key|
|
|
64
|
+
record = @store.get(SCOPE, key)
|
|
65
|
+
next false unless record && expired?(record)
|
|
66
|
+
|
|
67
|
+
@store.delete(SCOPE, key)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def expired?(record)
|
|
74
|
+
at = record["expires_at"]
|
|
75
|
+
return false if at.nil? # no expiry recorded -> keep (fail-safe, not fail-open)
|
|
76
|
+
|
|
77
|
+
Time.parse(at.to_s) <= @clock.call
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def key_for(key) = "#{KEY_PREFIX}#{key}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Applies the authored LLM providers (LLMProviderStore) to RubyLLM at RUNTIME.
|
|
5
|
+
# Goal: swap a provider's key/base WITHOUT a restart —
|
|
6
|
+
# RubyLLM exposes `config.<api>_api_key=` / `config.<api>_api_base=`, so
|
|
7
|
+
# reconfiguring is a matter of setting those accessors per provider.
|
|
8
|
+
#
|
|
9
|
+
# Core constraint: this file does NOT require ruby_llm at load-time — the
|
|
10
|
+
# `require` is lazy, inside `apply` (and injectable via `configure:` for tests,
|
|
11
|
+
# which run without the gem/key). A provider that RubyLLM doesn't recognize (no
|
|
12
|
+
# matching accessor) does NOT blow up: it goes into `skipped` (degrades to "restart
|
|
13
|
+
# recommended", like OpenClaw), the rest applies.
|
|
14
|
+
#
|
|
15
|
+
# ⚠️ GOTCHA — the DEFAULT target is a global singleton (RubyLLM research).
|
|
16
|
+
# With no `configure:`, `apply`/`unapply` mutate the PROCESS-WIDE config
|
|
17
|
+
# (`RubyLLM.config`). They are therefore admin operations (rare, operator-driven:
|
|
18
|
+
# a provider key/base edit in the Studio), NOT a per-request/per-turn path — a
|
|
19
|
+
# concurrent turn reading the config mid-mutation would observe a torn key/base.
|
|
20
|
+
# This is tolerable ONLY because reconfiguration is infrequent and single-writer
|
|
21
|
+
# (one reactor). Do NOT call this per turn, and do NOT reach for it to vary the
|
|
22
|
+
# MODEL per turn — the model is chosen at chat build time (ModelResolver ->
|
|
23
|
+
# chat(model:)), never by mutating the global.
|
|
24
|
+
#
|
|
25
|
+
# PER-GRAPH credentials are no longer hypothetical: the DSL runtime
|
|
26
|
+
# passes `configure:` targeting its own `RubyLLM.context` — an isolated config dup
|
|
27
|
+
# — so an operator's key edit in an EMBEDDED graph applies to that graph and stops
|
|
28
|
+
# there. See `DSL::Runtime#llm_configure` and docs/EMBEDDING.md.
|
|
29
|
+
class LLMConfigurator
|
|
30
|
+
def initialize(provider_store:, configure: nil)
|
|
31
|
+
@provider_store = provider_store
|
|
32
|
+
@configure = configure # ->(&blk){ blk.call(config_target) }; default = RubyLLM
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reconfigures RubyLLM with `providers` (raw records, with the real api_key) or,
|
|
36
|
+
# if nil, with ALL from the store. -> { applied: [api], skipped: [{api:, reason:}] }.
|
|
37
|
+
def apply(providers = nil)
|
|
38
|
+
records = providers || @provider_store.all_raw
|
|
39
|
+
applied = []
|
|
40
|
+
skipped = []
|
|
41
|
+
|
|
42
|
+
with_config do |config|
|
|
43
|
+
records.each do |rec|
|
|
44
|
+
api = fetch(rec, :api)
|
|
45
|
+
key = fetch(rec, :api_key)
|
|
46
|
+
if key.nil? || key.to_s.empty?
|
|
47
|
+
skipped << { api: api, reason: "sem api_key" }
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if set_accessor(config, "#{api}_api_key", key)
|
|
52
|
+
base = fetch(rec, :base_url)
|
|
53
|
+
set_accessor(config, "#{api}_api_base", base) if base && !base.to_s.empty?
|
|
54
|
+
applied << api
|
|
55
|
+
else
|
|
56
|
+
skipped << { api: api, reason: "provider '#{api}' not recognized by RubyLLM" }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ applied: applied, skipped: skipped }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# UNDOES a provider's config in RubyLLM at runtime (delete without a restart,
|
|
65
|
+
# clears `<api>_api_key`/`<api>_api_base`. A provider that RubyLLM
|
|
66
|
+
# doesn't recognize (no accessor) -> unapplied: false (nothing applied, nothing to undo).
|
|
67
|
+
# -> { unapplied: bool }.
|
|
68
|
+
def unapply(api)
|
|
69
|
+
api = api.to_s
|
|
70
|
+
unapplied = false
|
|
71
|
+
with_config do |config|
|
|
72
|
+
unapplied = set_accessor(config, "#{api}_api_key", nil)
|
|
73
|
+
set_accessor(config, "#{api}_api_base", nil)
|
|
74
|
+
end
|
|
75
|
+
{ unapplied: unapplied }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def with_config(&blk)
|
|
81
|
+
if @configure
|
|
82
|
+
@configure.call(&blk)
|
|
83
|
+
else
|
|
84
|
+
require "ruby_llm"
|
|
85
|
+
RubyLLM.configure(&blk)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def set_accessor(config, name, value)
|
|
90
|
+
setter = "#{name}="
|
|
91
|
+
return false unless config.respond_to?(setter)
|
|
92
|
+
|
|
93
|
+
config.public_send(setter, value)
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch(rec, key) = rec[key.to_s] || rec[key.to_sym]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# LLM providers authored at runtime. One record per
|
|
5
|
+
# provider in the ConfigStore (scope "llm_providers"), keyed by the API slug
|
|
6
|
+
# (`deepseek`, `openai`, ...). Holds base_url/auth_header/models and the `api_key`.
|
|
7
|
+
#
|
|
8
|
+
# The `api_key` NEVER leaves here in plaintext to the UI: the display reads
|
|
9
|
+
# (`get`/`all`) mask it with the `__OCULTO__` sentinel. Only `all_raw`/`get_raw`
|
|
10
|
+
# (consumed by the LLMConfigurator, never by the screen) return the real key.
|
|
11
|
+
# On write, the sentinel coming back preserves the key; a new string replaces it; ""
|
|
12
|
+
# clears it (see Insika::SecretMasking).
|
|
13
|
+
class LLMProviderStore
|
|
14
|
+
include Coercion
|
|
15
|
+
|
|
16
|
+
SCOPE = "llm_providers"
|
|
17
|
+
|
|
18
|
+
def initialize(config_store:)
|
|
19
|
+
@cs = config_store
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# -> MASKED Hash | nil.
|
|
23
|
+
def get(api)
|
|
24
|
+
mask(raw(api))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# -> Hash with REAL api_key | nil. Internal use (LLMConfigurator).
|
|
28
|
+
def get_raw(api)
|
|
29
|
+
raw(api)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# -> [String] slugs, lexicographic order.
|
|
33
|
+
def apis = @cs.keys(SCOPE)
|
|
34
|
+
|
|
35
|
+
# -> [Hash] all MASKED (for the UI).
|
|
36
|
+
def all
|
|
37
|
+
apis.filter_map { |a| get(a) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> [Hash] all with REAL api_key (for the configurator). Never goes to the screen.
|
|
41
|
+
def all_raw
|
|
42
|
+
apis.filter_map { |a| raw(a) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Upsert with secret reconciliation. `attrs` (string|symbol keys):
|
|
46
|
+
# api (required), base_url, auth_header, api_key (sentinel-aware), models[]
|
|
47
|
+
# -> MASKED Hash (the stored record).
|
|
48
|
+
def upsert(attrs)
|
|
49
|
+
h = symbolize(attrs)
|
|
50
|
+
api = presence(h[:api])
|
|
51
|
+
raise Insika::ValidationError, "api is required" if api.nil?
|
|
52
|
+
|
|
53
|
+
existing = raw(api)
|
|
54
|
+
record = {
|
|
55
|
+
"api" => api,
|
|
56
|
+
"base_url" => presence(h[:base_url]),
|
|
57
|
+
"auth_header" => presence(h[:auth_header]),
|
|
58
|
+
"api_key" => SecretMasking.reconcile(h[:api_key], existing&.fetch("api_key", nil)),
|
|
59
|
+
"models" => Array(h[:models]).map(&:to_s)
|
|
60
|
+
}
|
|
61
|
+
@cs.put(SCOPE, api, record)
|
|
62
|
+
mask(record)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# -> bool (did it exist?).
|
|
66
|
+
def delete(api) = @cs.delete(SCOPE, api.to_s)
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def raw(api) = @cs.get(SCOPE, api.to_s)
|
|
71
|
+
|
|
72
|
+
# Swaps the real api_key for the sentinel (or nil if absent) — never leaks plaintext.
|
|
73
|
+
def mask(record)
|
|
74
|
+
return nil if record.nil?
|
|
75
|
+
|
|
76
|
+
record.merge("api_key" => SecretMasking.mask(record["api_key"]))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def symbolize(attrs)
|
|
80
|
+
(attrs || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|