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,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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# LLM config v2 resolution. Turns the three config layers into a
|
|
5
|
+
# single ModelSelection at turn start:
|
|
6
|
+
#
|
|
7
|
+
# Chat (per-session pin) > Agent (profile.model) > Platform default (Settings)
|
|
8
|
+
#
|
|
9
|
+
# Also enforces the agent's `model_policy` on the RESOLVED model (a chat pin can
|
|
10
|
+
# never escape the fence) and resolves the fallback chain + selection source.
|
|
11
|
+
#
|
|
12
|
+
# Pure: no ruby_llm, no store writes. `settings_store` is optional — nil means
|
|
13
|
+
# "no platform layer" (an agent WITHOUT a model then fails high), preserving the
|
|
14
|
+
# pre-v2 behavior for the many call sites that build an Executor without one.
|
|
15
|
+
class ModelResolver
|
|
16
|
+
# Reserved, collision-safe slot for the per-session model pin inside
|
|
17
|
+
# `session.vars` (namespaced so it never renders in <request_context> — the
|
|
18
|
+
# Request provider skips "__"-prefixed vars).
|
|
19
|
+
SESSION_SLOT = "__llm__"
|
|
20
|
+
|
|
21
|
+
def initialize(settings_store: nil)
|
|
22
|
+
@settings_store = settings_store
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The layer that won resolution, before policy/fallback processing.
|
|
26
|
+
# `source` is where the model came from (:chat/:agent/:platform_default).
|
|
27
|
+
Choice = Data.define(:model, :provider, :source, :pinned)
|
|
28
|
+
private_constant :Choice
|
|
29
|
+
|
|
30
|
+
# profile: AgentProfile; session: SessionStore::Session | nil.
|
|
31
|
+
# -> ModelSelection. Raises PolicyDenied (model outside the agent's fence) or
|
|
32
|
+
# Error (no model resolvable at any layer).
|
|
33
|
+
def resolve(profile:, session: nil)
|
|
34
|
+
settings = platform_settings
|
|
35
|
+
choice = pick(profile, session, settings)
|
|
36
|
+
|
|
37
|
+
if blank?(choice.model)
|
|
38
|
+
raise Insika::Error,
|
|
39
|
+
"no model resolved for agent '#{profile.id}': set the agent model or a platform default_model (Settings)"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
provider = choice.provider&.to_sym
|
|
43
|
+
enforce_policy!(profile, choice.model, provider)
|
|
44
|
+
|
|
45
|
+
ModelSelection.new(
|
|
46
|
+
model: choice.model, provider: provider, source: choice.source, pinned: choice.pinned,
|
|
47
|
+
params: resolve_params(profile, session, settings, choice.model, provider),
|
|
48
|
+
fallbacks: choice.pinned ? [] : resolve_fallbacks(profile, settings, choice.model, provider)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Chat pin > agent model > platform default.
|
|
55
|
+
def pick(profile, session, settings)
|
|
56
|
+
pin = session_pin(session)
|
|
57
|
+
if pin && !blank?(pin[:model])
|
|
58
|
+
Choice.new(model: pin[:model], provider: pin[:provider], source: :chat, pinned: true)
|
|
59
|
+
elsif !blank?(profile.model)
|
|
60
|
+
Choice.new(model: profile.model, provider: profile.provider, source: :agent, pinned: false)
|
|
61
|
+
else
|
|
62
|
+
Choice.new(model: settings["default_model"], provider: settings["default_provider"],
|
|
63
|
+
source: :platform_default, pinned: false)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Per-session pin from vars["__llm__"] = { "model" =>, "provider" => }.
|
|
68
|
+
# SessionStore deep_stringifies vars on write, so the slot is always
|
|
69
|
+
# string-keyed. -> { model:, provider: } | nil.
|
|
70
|
+
def session_pin(session)
|
|
71
|
+
slot = session&.vars&.dig(SESSION_SLOT)
|
|
72
|
+
return nil unless slot.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
{ model: slot["model"], provider: slot["provider"] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def enforce_policy!(profile, model, provider)
|
|
78
|
+
policy = model_policy(profile)
|
|
79
|
+
return if ModelPolicy.allowed?(policy, model: model, provider: provider)
|
|
80
|
+
|
|
81
|
+
raise Insika::PolicyDenied.new(
|
|
82
|
+
policy: :model_policy,
|
|
83
|
+
reason: "model '#{ref(model, provider)}' not allowed for agent '#{profile.id}' " \
|
|
84
|
+
"(allow: #{ModelPolicy.allow_list(policy).inspect})"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Platform fallback chain (Settings["fallback_models"]) minus the primary,
|
|
89
|
+
# filtered by the agent's model_policy. Each entry: "provider/model" | "model".
|
|
90
|
+
def resolve_fallbacks(profile, settings, primary_model, primary_provider)
|
|
91
|
+
policy = model_policy(profile)
|
|
92
|
+
Array(settings["fallback_models"]).filter_map do |entry|
|
|
93
|
+
model, provider = parse_ref(entry)
|
|
94
|
+
next if model.nil?
|
|
95
|
+
next if model == primary_model && provider == primary_provider # dedupe the primary
|
|
96
|
+
next unless ModelPolicy.allowed?(policy, model: model, provider: provider)
|
|
97
|
+
|
|
98
|
+
{ model: model, provider: provider }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# "provider/model" -> [model, :provider]; "model" -> [model, nil].
|
|
103
|
+
def parse_ref(entry)
|
|
104
|
+
s = entry.to_s.strip
|
|
105
|
+
return [nil, nil] if s.empty?
|
|
106
|
+
|
|
107
|
+
if s.include?("/")
|
|
108
|
+
provider, model = s.split("/", 2)
|
|
109
|
+
[model, presence(provider)&.to_sym]
|
|
110
|
+
else
|
|
111
|
+
[s, nil]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def platform_settings
|
|
116
|
+
@settings_store ? @settings_store.get : {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# AgentProfile always carries `params` (a string-keyed Hash, {} when unset)
|
|
120
|
+
# and `model_policy` (nil = no fence) — no feature-detection needed.
|
|
121
|
+
def profile_params(profile) = profile.params
|
|
122
|
+
def model_policy(profile) = profile.model_policy
|
|
123
|
+
|
|
124
|
+
# Generation params: the agent's temperature/max_tokens plus the reasoning
|
|
125
|
+
# control (`thinking`) resolved across the 4 layers. A resolved nil means
|
|
126
|
+
# "nothing set anywhere" -> drop the key so the provider default stands.
|
|
127
|
+
def resolve_params(profile, session, settings, model, provider)
|
|
128
|
+
params = normalize_params(profile_params(profile))
|
|
129
|
+
thinking = resolve_thinking(profile, session, settings, model, provider)
|
|
130
|
+
thinking.nil? ? params.tap { |p| p.delete(:thinking) } : params.merge(thinking: thinking)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# First non-blank of Chat > Agent > Model-default > Global (most specific wins).
|
|
134
|
+
def resolve_thinking(profile, session, settings, model, provider)
|
|
135
|
+
[
|
|
136
|
+
chat_thinking(session),
|
|
137
|
+
presence(profile.params["thinking"]),
|
|
138
|
+
model_thinking(settings, model, provider),
|
|
139
|
+
presence(settings["thinking"])
|
|
140
|
+
].find { |v| !blank?(v) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Per-chat override: the `thinking` slot in the session pin (vars["__llm__"]),
|
|
144
|
+
# independent of whether the chat also pinned a model.
|
|
145
|
+
def chat_thinking(session)
|
|
146
|
+
slot = session&.vars&.dig(SESSION_SLOT)
|
|
147
|
+
slot.is_a?(Hash) ? presence(slot["thinking"]) : nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Per-model default: settings["model_params"][ref]["thinking"], matched by the
|
|
151
|
+
# full "provider/model" ref first, then the bare model id.
|
|
152
|
+
def model_thinking(settings, model, provider)
|
|
153
|
+
map = settings["model_params"]
|
|
154
|
+
return nil unless map.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
ref = provider ? "#{provider}/#{model}" : nil
|
|
157
|
+
entry = (ref && map[ref]) || map[model.to_s]
|
|
158
|
+
entry.is_a?(Hash) ? presence(entry["thinking"]) : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Coerces authored params (string|symbol keys from the JSON round-trip) into
|
|
162
|
+
# the symbol shape ModelSelection#apply_params consumes.
|
|
163
|
+
def normalize_params(params)
|
|
164
|
+
out = {}
|
|
165
|
+
params.each do |k, v|
|
|
166
|
+
key = k.to_sym
|
|
167
|
+
out[key] = v if %i[temperature max_tokens thinking].include?(key)
|
|
168
|
+
end
|
|
169
|
+
out
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ref(model, provider) = provider ? "#{provider}/#{model}" : model.to_s
|
|
173
|
+
def blank?(v) = Insika::Coercion.blank?(v)
|
|
174
|
+
def presence(v) = Insika::Coercion.presence(v)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The RESOLVED model decision for a turn (ModelResolver output). Immutable
|
|
5
|
+
# snapshot carried on the TurnState, applied to the RubyLLM chat at stage 5 and
|
|
6
|
+
# surfaced in the turn's usage for telemetry/billing.
|
|
7
|
+
#
|
|
8
|
+
# Fields:
|
|
9
|
+
# model -> the resolved model id (String) | nil (RubyLLM's own default)
|
|
10
|
+
# provider -> Symbol | nil (nil => RubyLLM infers from the model registry)
|
|
11
|
+
# source -> :chat | :agent | :platform_default (WHERE the model came from)
|
|
12
|
+
# pinned -> true when the model is a USER pin (per-chat override). A pin
|
|
13
|
+
# "fails HIGH": no silent fallback (OpenClaw semantics). An agent
|
|
14
|
+
# model / platform default is NOT pinned -> fallbacks apply.
|
|
15
|
+
# params -> generation params (Hash of symbols: temperature/max_tokens/thinking)
|
|
16
|
+
# fallbacks -> ordered [{ model:, provider: }] to try when NOT pinned. The
|
|
17
|
+
# mid-turn ROTATION across this chain (plus the profile's own
|
|
18
|
+
# reliability["fallback"] refs) is WS3's Reliability coordinator;
|
|
19
|
+
# here the chain is resolved + surfaced (source/pinned) for it.
|
|
20
|
+
ModelSelection = Data.define(:model, :provider, :source, :pinned, :params, :fallbacks) do
|
|
21
|
+
def initialize(model:, provider: nil, source: :platform_default, pinned: false,
|
|
22
|
+
params: {}, fallbacks: [])
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pinned? = pinned
|
|
27
|
+
|
|
28
|
+
# provider present => tell RubyLLM to trust the id (skip the registry lookup),
|
|
29
|
+
# matching the pre-v2 create_chat behavior.
|
|
30
|
+
def assume_model_exists? = !provider.nil?
|
|
31
|
+
|
|
32
|
+
# Applies the generation params to a RubyLLM chat (or any object exposing the
|
|
33
|
+
# same `with_*` fluent API). Pure and duck-typed: a `with_*` the target does
|
|
34
|
+
# not expose is skipped (a fake chat in specs need only implement the ones it
|
|
35
|
+
# asserts). RubyLLM raises on a nil arg, so only PRESENT params are applied.
|
|
36
|
+
# Returns the chat (the `with_*` calls mutate and return self).
|
|
37
|
+
def apply_params(chat)
|
|
38
|
+
p = params || {}
|
|
39
|
+
apply(chat, :with_temperature, p[:temperature]) if numeric?(p[:temperature])
|
|
40
|
+
apply_payload_params(chat, p)
|
|
41
|
+
apply_effort(chat, p[:thinking]) if present?(p[:thinking])
|
|
42
|
+
chat
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def apply(chat, method, value)
|
|
48
|
+
chat.public_send(method, value) if chat.respond_to?(method)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The params that ride RubyLLM's `with_params` (deep-merged OVER the provider
|
|
52
|
+
# payload, so they win over a provider default). ONE call on purpose:
|
|
53
|
+
# `with_params` REPLACES the whole hash — a second call would silently drop
|
|
54
|
+
# the first one's keys.
|
|
55
|
+
#
|
|
56
|
+
# max_tokens goes here because the gem has NO with_max_* setter: the chat's
|
|
57
|
+
# fluent API covers temperature/thinking only, and Anthropic is the only
|
|
58
|
+
# provider that emits a cap at all (from the model registry, 4096 by default).
|
|
59
|
+
# `max_tokens` is the wire key for Anthropic and for every OpenAI-compatible
|
|
60
|
+
# provider (DeepSeek included); OpenAI's o-series wants
|
|
61
|
+
# `max_completion_tokens` instead — that per-provider mapping is not modelled
|
|
62
|
+
# yet, and a raw provider key is not authorable (the profile whitelists
|
|
63
|
+
# temperature/max_tokens/thinking).
|
|
64
|
+
def apply_payload_params(chat, p)
|
|
65
|
+
return unless chat.respond_to?(:with_params)
|
|
66
|
+
|
|
67
|
+
payload = {}
|
|
68
|
+
payload[:max_tokens] = p[:max_tokens] if numeric?(p[:max_tokens])
|
|
69
|
+
toggle = thinking_toggle(p[:thinking])
|
|
70
|
+
payload[:thinking] = { type: toggle } if toggle
|
|
71
|
+
chat.with_params(**payload) unless payload.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The resolved reasoning control (4-layer). Two axes folded into one field:
|
|
75
|
+
# off -> reasoning DISABLED (thinking:{type:disabled})
|
|
76
|
+
# on -> reasoning ENABLED, provider-default effort
|
|
77
|
+
# low|medium|high -> reasoning enabled at that effort (reasoning_effort)
|
|
78
|
+
# Blank/nil never reaches here (apply_params guards on present?), so no config
|
|
79
|
+
# anywhere = the provider's own default.
|
|
80
|
+
#
|
|
81
|
+
# ruby_llm 1.16's with_thinking only EMITS reasoning_effort and cannot toggle
|
|
82
|
+
# on/off; the on/off ride on with_params (deep-merged over the payload). The
|
|
83
|
+
# thinking:{type:} shape is DeepSeek's OpenAI-compat contract — gated to it
|
|
84
|
+
# (nil provider = the platform default, DeepSeek here); other providers get
|
|
85
|
+
# effort-only until their toggle wire is mapped (e.g. Anthropic output_config).
|
|
86
|
+
def thinking_toggle(value)
|
|
87
|
+
return nil unless present?(value) && reasoning_toggle_supported?
|
|
88
|
+
|
|
89
|
+
case value.to_s
|
|
90
|
+
when "off" then "disabled"
|
|
91
|
+
when "on" then "enabled"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def apply_effort(chat, value)
|
|
96
|
+
return unless %w[low medium high].include?(value.to_s)
|
|
97
|
+
|
|
98
|
+
chat.with_thinking(effort: value.to_sym) if chat.respond_to?(:with_thinking)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reasoning_toggle_supported?
|
|
102
|
+
provider.nil? || provider.to_sym == :deepseek
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def numeric?(v) = v.is_a?(Numeric)
|
|
106
|
+
def present?(v) = Insika::Coercion.present?(v)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The selectable reasoning values (4-layer). Blank/absent = inherit the
|
|
110
|
+
# broader layer; these are the explicit choices. Shared with the Studio forms.
|
|
111
|
+
# Defined on the class OUTSIDE the Data.define block on purpose: a constant
|
|
112
|
+
# assigned inside the block would land in the enclosing lexical scope (Insika),
|
|
113
|
+
# not on ModelSelection.
|
|
114
|
+
ModelSelection::THINKING_LEVELS = %w[off on low medium high].freeze
|
|
115
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# LLM-first onboarding surface. The "Flue trick": the insika
|
|
5
|
+
# serves, from itself, a `start.md` addressed to the DEVELOPER'S OWN coding agent
|
|
6
|
+
# ("Read <base>/start.md then help me build my first agent") plus a machine-readable
|
|
7
|
+
# `/models.json` and the public docs mirrored as raw markdown. It is `rails new`
|
|
8
|
+
# reimplemented as a prompt, with the generator being the coding agent the developer
|
|
9
|
+
# already has.
|
|
10
|
+
#
|
|
11
|
+
# Pure and data-defined: everything it serves comes from injected sources — a
|
|
12
|
+
# `start.md` TEMPLATE file, a NAME=>path map of public docs, and (optionally) the
|
|
13
|
+
# SettingsStore / LLMProviderStore / the served agents. It only READS (no writes, no
|
|
14
|
+
# RubyLLM, no Executor), so the transport can call it under the constitutional rule.
|
|
15
|
+
# Secrets never leak: `models_json` reads the MASKED provider view and omits base
|
|
16
|
+
# urls/keys entirely — only slugs and model ids, which is all a coding agent needs to
|
|
17
|
+
# write a correct `model`/`provider` line.
|
|
18
|
+
#
|
|
19
|
+
# Injection (all optional but `template_path`/`docs`):
|
|
20
|
+
# template_path -> the start.md source (a real file, so it stays reviewable/raw)
|
|
21
|
+
# docs -> { "slug" => "/abs/path.md", … } served at GET /docs/:slug.md.
|
|
22
|
+
# An explicit ALLOWLIST — the gitignored internal docs are never
|
|
23
|
+
# in it, and there is no filesystem traversal (only map keys).
|
|
24
|
+
# settings_store -> platform default_model / fallbacks / thinking (nil = omit)
|
|
25
|
+
# provider_store -> configured providers + their model ids, MASKED (nil = omit)
|
|
26
|
+
# agents -> callable returning [{ id:, model:, provider:, description: }]
|
|
27
|
+
# for the agents already served here (nil = none)
|
|
28
|
+
class Onboarding
|
|
29
|
+
# Bumped when the models.json SHAPE changes (its own contract, independent of the
|
|
30
|
+
# settings schema). A consuming coding agent can branch on it.
|
|
31
|
+
MODELS_SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
# The PUBLIC docs allowlist, repo-relative: slug => path. Explicit on purpose —
|
|
34
|
+
# the gitignored internal docs (/ TRANSLATION-TRACKER / …)
|
|
35
|
+
# are NEVER here, so /docs can only ever serve OSS material. Kept in sync with the
|
|
36
|
+
# tracked `.md` prose (README + docs/*.md).
|
|
37
|
+
PUBLIC_DOCS = {
|
|
38
|
+
"readme" => "README.md",
|
|
39
|
+
"why" => "docs/WHY.md",
|
|
40
|
+
"agents" => "docs/AGENTS.md",
|
|
41
|
+
"tools" => "docs/TOOLS.md",
|
|
42
|
+
"skills" => "docs/SKILLS.md",
|
|
43
|
+
"context" => "docs/CONTEXT.md",
|
|
44
|
+
"workflows" => "docs/WORKFLOWS.md",
|
|
45
|
+
"channels" => "docs/CHANNELS.md",
|
|
46
|
+
"plugins" => "docs/PLUGINS.md",
|
|
47
|
+
"security" => "docs/SECURITY.md",
|
|
48
|
+
"architecture" => "docs/ARCHITECTURE.md",
|
|
49
|
+
"running-local" => "docs/RUNNING-LOCAL.md",
|
|
50
|
+
"deploy" => "docs/DEPLOY.md",
|
|
51
|
+
"embedding" => "docs/EMBEDDING.md",
|
|
52
|
+
"sandbox" => "docs/SANDBOX.md",
|
|
53
|
+
"benchmark" => "docs/BENCHMARK.md",
|
|
54
|
+
"observability" => "docs/OBSERVABILITY.md",
|
|
55
|
+
"evals" => "docs/EVALS.md",
|
|
56
|
+
"refinement" => "docs/REFINEMENT.md",
|
|
57
|
+
"loadtest" => "docs/LOADTEST.md",
|
|
58
|
+
"releasing" => "docs/RELEASING.md"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Repo-relative path to the start.md template.
|
|
62
|
+
TEMPLATE = "docs/onboarding/start.md"
|
|
63
|
+
|
|
64
|
+
# Builds the standard onboarding surface rooted at `root` (the repo/gem root),
|
|
65
|
+
# wiring the PUBLIC_DOCS allowlist + start.md template. The three composition roots
|
|
66
|
+
# (minimal wiring, DSL serve, deployment) pass their own stores/agents on top. A
|
|
67
|
+
# doc whose file is absent (a slimmed-down gem) is simply dropped — never a boot
|
|
68
|
+
# failure.
|
|
69
|
+
def self.standard(root:, settings_store: nil, provider_store: nil, agents: nil)
|
|
70
|
+
docs = PUBLIC_DOCS.each_with_object({}) do |(slug, rel), acc|
|
|
71
|
+
path = File.join(root, rel)
|
|
72
|
+
acc[slug] = path if File.file?(path)
|
|
73
|
+
end
|
|
74
|
+
new(template_path: File.join(root, TEMPLATE), docs: docs,
|
|
75
|
+
settings_store: settings_store, provider_store: provider_store, agents: agents)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(template_path:, docs: {}, settings_store: nil, provider_store: nil, agents: nil)
|
|
79
|
+
@template_path = template_path
|
|
80
|
+
@docs = docs || {}
|
|
81
|
+
@settings_store = settings_store
|
|
82
|
+
@provider_store = provider_store
|
|
83
|
+
@agents = agents # callable -> [Hash] | nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The onboarding skill (raw markdown), with the live base url interpolated so the
|
|
87
|
+
# coding agent knows where to fetch the models list and docs. Read on each request
|
|
88
|
+
# (the file is small and this surface is low-traffic) — editing start.md needs no
|
|
89
|
+
# restart.
|
|
90
|
+
def start_md(base_url:)
|
|
91
|
+
base = normalize_base(base_url)
|
|
92
|
+
substitute(File.read(@template_path), base)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Machine-readable model catalog. Everything a coding agent needs to write a valid
|
|
96
|
+
# `model`/`provider` line, and nothing secret. Sources that are absent (nil store)
|
|
97
|
+
# simply drop their key — a fresh DSL serve still returns a coherent document
|
|
98
|
+
# (served agents + thinking levels + whatever default is set).
|
|
99
|
+
def models_json(base_url:)
|
|
100
|
+
base = normalize_base(base_url)
|
|
101
|
+
settings = @settings_store&.get || {}
|
|
102
|
+
{
|
|
103
|
+
schema_version: MODELS_SCHEMA_VERSION,
|
|
104
|
+
base_url: base,
|
|
105
|
+
responses_url: "#{base}/v1/responses",
|
|
106
|
+
default: default_model(settings),
|
|
107
|
+
fallbacks: fallback_models(settings),
|
|
108
|
+
utility_model: presence(settings["utility_model"]),
|
|
109
|
+
thinking_levels: ModelSelection::THINKING_LEVELS,
|
|
110
|
+
providers: providers,
|
|
111
|
+
agents: served_agents
|
|
112
|
+
}.compact
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The public docs index (name + title + fetchable raw-markdown url). Drives
|
|
116
|
+
# discovery: a coding agent lists this, then GETs the ones it needs.
|
|
117
|
+
def docs_index(base_url:)
|
|
118
|
+
base = normalize_base(base_url)
|
|
119
|
+
@docs.keys.sort.map do |slug|
|
|
120
|
+
{ name: slug, title: doc_title(slug), url: "#{base}/docs/#{slug}.md" }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Raw markdown for one public doc, by slug. nil = unknown slug (the transport 404s).
|
|
125
|
+
# No path traversal is possible: `slug` must be a KEY of the injected allowlist.
|
|
126
|
+
#
|
|
127
|
+
# The Jekyll frontmatter the docs site needs (title/parent/nav_order — the same
|
|
128
|
+
# files ARE the site's pages) is STRIPPED here: a coding agent asked for the prose,
|
|
129
|
+
# not for sidebar metadata, and this keeps the response byte-identical to what it
|
|
130
|
+
# was before the site existed.
|
|
131
|
+
def doc(slug)
|
|
132
|
+
read_doc(slug)&.sub(FRONTMATTER, "")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Leading YAML block, plus the blank line after it, so the body still starts at
|
|
138
|
+
# its `# Heading`. Same shape SkillCatalog accepts.
|
|
139
|
+
FRONTMATTER = /\A---\s*\n(.*?)\n---\s*\n+/m
|
|
140
|
+
private_constant :FRONTMATTER
|
|
141
|
+
|
|
142
|
+
# Whole file, frontmatter included. nil = unknown slug or a file that vanished.
|
|
143
|
+
def read_doc(slug)
|
|
144
|
+
path = @docs[slug.to_s]
|
|
145
|
+
return nil if path.nil? || !File.file?(path)
|
|
146
|
+
|
|
147
|
+
File.read(path)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def substitute(text, base)
|
|
151
|
+
text
|
|
152
|
+
.gsub("{{BASE_URL}}", base)
|
|
153
|
+
.gsub("{{MODELS_URL}}", "#{base}/models.json")
|
|
154
|
+
.gsub("{{DOCS_URL}}", "#{base}/docs")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# default_model/default_provider -> { model:, provider: } | nil (nothing set).
|
|
158
|
+
def default_model(settings)
|
|
159
|
+
model = presence(settings["default_model"])
|
|
160
|
+
return nil if model.nil?
|
|
161
|
+
|
|
162
|
+
{ model: model, provider: presence(settings["default_provider"]) }.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Platform fallback chain as ["provider/model" | "model", …]; blank entries dropped.
|
|
166
|
+
def fallback_models(settings)
|
|
167
|
+
Array(settings["fallback_models"]).filter_map { |entry| presence(entry) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Providers a coding agent can target, from the MASKED store view: slug + the
|
|
171
|
+
# model ids. Deliberately NO base_url / auth / key — model ids and slugs are all
|
|
172
|
+
# that a `model`/`provider` line needs, and nothing here is a secret.
|
|
173
|
+
def providers
|
|
174
|
+
return nil if @provider_store.nil?
|
|
175
|
+
|
|
176
|
+
@provider_store.all.map do |rec|
|
|
177
|
+
{ slug: rec["api"].to_s, models: Array(rec["models"]).map(&:to_s) }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Agents already served by THIS insika — over the drop-in API the `model` field
|
|
182
|
+
# is the agent id, so this is the list of ids a client can call right now.
|
|
183
|
+
def served_agents
|
|
184
|
+
list = @agents.respond_to?(:call) ? Array(@agents.call) : nil
|
|
185
|
+
return nil if list.nil? || list.empty?
|
|
186
|
+
|
|
187
|
+
list.map do |a|
|
|
188
|
+
{ id: a[:id].to_s, model: presence(a[:model]), provider: presence(a[:provider]),
|
|
189
|
+
description: presence(a[:description]) }.compact
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Frontmatter `title` (what the site's sidebar shows — one source for both
|
|
194
|
+
# surfaces), else the first markdown heading, else the humanized slug.
|
|
195
|
+
def doc_title(slug)
|
|
196
|
+
raw = read_doc(slug)
|
|
197
|
+
front = raw&.match(FRONTMATTER)
|
|
198
|
+
title = front && presence(Frontmatter.parse(front[1])["title"])
|
|
199
|
+
return title if title
|
|
200
|
+
|
|
201
|
+
heading = raw&.lines&.find { |l| l.start_with?("# ") }
|
|
202
|
+
heading ? heading.sub(/\A#\s*/, "").strip : slug.tr("-_", " ").capitalize
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def normalize_base(base_url) = base_url.to_s.sub(%r{/+\z}, "")
|
|
206
|
+
def presence(value) = Insika::Coercion.presence(value)
|
|
207
|
+
end
|
|
208
|
+
end
|