insika 0.8.0 → 1.0.1
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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/mcp_client.rb
CHANGED
|
@@ -1,94 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# Streamable HTTP and SSE with the full lifecycle (initialize handshake,
|
|
7
|
-
# session-id, notifications) — Insika stops maintaining its own protocol
|
|
8
|
-
# client. Swapping the gem later (e.g. to modelcontextprotocol/ruby-sdk)
|
|
9
|
-
# touches only this file.
|
|
10
|
-
#
|
|
11
|
-
# `require "ruby_llm/mcp"` is LAZY, done here rather than at `require
|
|
12
|
-
# "insika"` time (spec/insika/load_guard_spec.rb): the gem drags in
|
|
13
|
-
# ruby_llm + httpx, the same opt-in-cost discipline as every other provider
|
|
14
|
-
# surface.
|
|
15
|
-
#
|
|
16
|
-
# Client LIFECYCLE (start/stop, registering the child process with
|
|
17
|
-
# Insika::Shutdown so it dies on SIGTERM) belongs to whoever holds the
|
|
18
|
-
# client across calls — the live tool registry, later work. `for` itself
|
|
19
|
-
# returns an unstarted client (`start: false`); building one is cheap and
|
|
20
|
-
# side-effect-free.
|
|
4
|
+
# Maps persisted MCP configuration to RubyLLM's native, lazy client.
|
|
5
|
+
# Insika retains the stdio opt-in and deployment egress policy.
|
|
21
6
|
module McpClient
|
|
22
|
-
# A stdio instance is arbitrary command execution by config — refused
|
|
23
|
-
# unless the operator opted in (config-over-convention, same pattern as
|
|
24
|
-
# the egress envs).
|
|
25
7
|
class StdioDisabled < Insika::Error; end
|
|
26
8
|
|
|
27
9
|
module_function
|
|
28
10
|
|
|
29
|
-
# `record` — the RAW McpStore record (get_raw/all_raw; never the masked
|
|
30
|
-
# one — a masked record's credentials are the __OCULTO__ sentinel, not
|
|
31
|
-
# something a client can connect with).
|
|
32
|
-
# -> RubyLLM::MCP::Client, not started. Raises StdioDisabled (stdio, gate
|
|
33
|
-
# off), Insika::Error (http/sse, egress-blocked url) or
|
|
34
|
-
# Insika::ValidationError (unknown transport).
|
|
35
11
|
def for(record, env_reader: Insika::EnvSchema, egress: Insika::EgressGuard)
|
|
36
|
-
require "ruby_llm
|
|
37
|
-
apply_origin_header_fix!
|
|
12
|
+
require "ruby_llm"
|
|
38
13
|
|
|
39
14
|
case record["transport"].to_s
|
|
40
15
|
when "stdio" then stdio_client(record, env_reader: env_reader)
|
|
41
|
-
when "http" then http_client(record, :
|
|
42
|
-
when "sse"
|
|
16
|
+
when "http" then http_client(record, egress: egress, env_reader: env_reader)
|
|
17
|
+
when "sse"
|
|
18
|
+
raise Insika::ValidationError,
|
|
19
|
+
"MCP instance '#{record['name']}' uses legacy SSE; configure its Streamable HTTP endpoint instead"
|
|
43
20
|
else
|
|
44
21
|
raise Insika::ValidationError,
|
|
45
|
-
"MCP instance '#{record[
|
|
22
|
+
"MCP instance '#{record['name']}' has an unknown transport: #{record['transport'].inspect}"
|
|
46
23
|
end
|
|
47
24
|
end
|
|
48
25
|
|
|
26
|
+
# The operator registers an MCP URL and the model cannot change it, so the
|
|
27
|
+
# host allowlist (which pins model-shaped data-tool requests) only pins
|
|
28
|
+
# private destinations: a public server needs no redeploy to be added.
|
|
29
|
+
def egress_options(env_reader)
|
|
30
|
+
hosts = env_reader.read("INSIKA_EGRESS_HOSTS").to_s.split(",").map(&:strip).reject(&:empty?)
|
|
31
|
+
{ allow_http: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_HTTP")),
|
|
32
|
+
allow_private: env_reader.truthy?(env_reader.read("INSIKA_EGRESS_ALLOW_PRIVATE")),
|
|
33
|
+
host_allowlist: (hosts unless hosts.empty?), pin_private_only: true }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
49
36
|
def stdio_client(record, env_reader:)
|
|
50
37
|
unless env_reader.truthy?(env_reader.read("INSIKA_MCP_STDIO"))
|
|
51
38
|
raise StdioDisabled,
|
|
52
|
-
"MCP instance '#{record[
|
|
39
|
+
"MCP instance '#{record['name']}' is stdio (arbitrary command execution by config) — " \
|
|
53
40
|
"set INSIKA_MCP_STDIO=1 to allow it to start"
|
|
54
41
|
end
|
|
55
42
|
|
|
56
|
-
RubyLLM
|
|
57
|
-
|
|
58
|
-
config: { command: record["command"], args: Array(record["args"]), env: record["env"] || {} }
|
|
59
|
-
)
|
|
43
|
+
RubyLLM.mcp(name: record["name"], command: [record["command"], *Array(record["args"])],
|
|
44
|
+
env: record["env"] || {})
|
|
60
45
|
end
|
|
61
46
|
|
|
62
|
-
def http_client(record,
|
|
47
|
+
def http_client(record, egress:, env_reader:)
|
|
63
48
|
url = record["url"]
|
|
64
|
-
reason = egress.violation(url)
|
|
49
|
+
reason = egress.violation(url, **egress_options(env_reader))
|
|
65
50
|
raise Insika::Error, "MCP target blocked: #{reason}" if reason
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
name: record["name"], transport_type: transport_type, start: false,
|
|
69
|
-
config: { url: url, headers: record["headers"] || {} }
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# https://github.com/patvice/ruby_llm-mcp/issues/140 (open, unfixed as of
|
|
74
|
-
# 1.0.1): StreamableHTTP#build_common_headers sets `Origin` to the MCP
|
|
75
|
-
# endpoint's OWN url (path included) on every request. That's invalid per
|
|
76
|
-
# RFC 6454 §7 (Origin is scheme+host[+port], never a path) and unneeded —
|
|
77
|
-
# a server-to-server client has no browser Origin to report, and the
|
|
78
|
-
# reference TS/Python SDKs never send one. No allowlist can ever match a
|
|
79
|
-
# value that carries a path, so any server enforcing the spec's
|
|
80
|
-
# DNS-rebinding Origin check (GitHub's remote MCP, Grafana, Metabase, ...)
|
|
81
|
-
# rejects every request. Same prepend the issue's own author ships in
|
|
82
|
-
# production. `Module#prepend` no-ops if the module is already in the
|
|
83
|
-
# ancestor chain, so calling this on every `for` is cheap and safe.
|
|
84
|
-
def apply_origin_header_fix!
|
|
85
|
-
RubyLLM::MCP::Native::Transports::StreamableHTTP.prepend(OriginHeaderFix)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
module OriginHeaderFix
|
|
89
|
-
def build_common_headers
|
|
90
|
-
super.except("Origin")
|
|
51
|
+
unless RubyLLM::MCP::HTTP.secure?(url)
|
|
52
|
+
raise Insika::ValidationError, "Native MCP requires HTTPS without URL credentials, except on loopback hosts"
|
|
91
53
|
end
|
|
54
|
+
|
|
55
|
+
RubyLLM.mcp(name: record["name"], url: url, headers: record["headers"] || {})
|
|
92
56
|
end
|
|
93
57
|
end
|
|
94
58
|
end
|
data/lib/insika/mcp_live_tool.rb
CHANGED
|
@@ -1,43 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "ruby_llm"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
|
-
# One live MCP tool. `name`/`description`/`
|
|
7
|
+
# One live MCP tool. `name`/`description`/`parameters_schema`
|
|
7
8
|
# come from the CACHED descriptor (McpStore#tools_cache — no I/O to build
|
|
8
9
|
# this instance, same cost as any other tool the registry hands out).
|
|
9
10
|
# `#execute` is the only thing that touches the network, calling through
|
|
10
|
-
# `client_for` (Insika::McpToolRegistry's memoized,
|
|
11
|
-
#
|
|
12
|
-
# `RubyLLM::MCP::Tool#execute`, which already unwraps `content[].text` and
|
|
11
|
+
# `client_for` (Insika::McpToolRegistry's memoized, lazy, per-conversation client) into
|
|
12
|
+
# `RubyLLM::MCP::Tool#call`, which already unwraps `content[].text` and
|
|
13
13
|
# turns `isError` into `{error:}`.
|
|
14
14
|
#
|
|
15
15
|
# Like every tool in this codebase, `#execute` NEVER raises: a connection
|
|
16
16
|
# or protocol failure becomes `{error:}`, the model's normal failure path
|
|
17
17
|
# (see Tools::DataDefinedTool's own rule).
|
|
18
18
|
class McpLiveTool < RubyLLM::Tool
|
|
19
|
-
|
|
19
|
+
# `name:` is what the model calls (the registry's instance-qualified name);
|
|
20
|
+
# the server is still asked for its own tool name.
|
|
21
|
+
def initialize(instance_name:, tool:, client_for:, overrides: {}, name: tool["name"])
|
|
20
22
|
@instance_name = instance_name
|
|
21
23
|
@tool = tool
|
|
24
|
+
@name = name
|
|
22
25
|
@client_for = client_for
|
|
26
|
+
@evidence = overrides["evidence"] && Insika::Evidence::Spec.parse(overrides["evidence"])
|
|
27
|
+
@requires_evidence = Insika::ToolDefinition.normalize_requires_evidence(
|
|
28
|
+
overrides["requires_evidence"], top_level_params
|
|
29
|
+
)
|
|
23
30
|
super()
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def name = @
|
|
33
|
+
def name = @name
|
|
27
34
|
def description = @tool["description"].to_s
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
# The two questions the envelope asks any tool: what of this result is
|
|
37
|
+
# evidence, and which of these parameters may only carry an id some tool
|
|
38
|
+
# already returned. A server cannot answer either — it does not know what the
|
|
39
|
+
# deployment trusts — so they come from the instance's own configuration and
|
|
40
|
+
# are nil for every tool nobody configured, which is what they were before.
|
|
41
|
+
attr_reader :evidence, :requires_evidence
|
|
42
|
+
|
|
43
|
+
def parameters_schema
|
|
30
44
|
schema = @tool["inputSchema"]
|
|
31
45
|
schema.nil? || schema.empty? ? { "type" => "object", "properties" => {} } : schema
|
|
32
46
|
end
|
|
33
47
|
|
|
48
|
+
# The conversation this instance serves, deposited by ToolAssembly. An MCP
|
|
49
|
+
# server keeps state per MCP session (a target store, a pending
|
|
50
|
+
# confirmation token), so each conversation gets its own client.
|
|
51
|
+
def turn_context=(ctx)
|
|
52
|
+
@session_id = ctx && (ctx[:session_id] || ctx["session_id"])
|
|
53
|
+
end
|
|
54
|
+
|
|
34
55
|
def execute(**params)
|
|
35
|
-
live = @client_for.call.tool
|
|
56
|
+
live = @client_for.call(@session_id).tools.find { |tool| tool.name == @tool["name"] }
|
|
36
57
|
raise Insika::NotFoundError, "tool '#{@tool["name"]}' no longer offered" if live.nil?
|
|
37
58
|
|
|
38
|
-
live.
|
|
59
|
+
result = live.call(**params)
|
|
60
|
+
# Hand the envelope what a chat would send the model (text, JSON of the
|
|
61
|
+
# structured content, or text plus attachments), not the gem's Result.
|
|
62
|
+
parsed(result.is_a?(RubyLLM::MCP::Result) ? result.content : result)
|
|
39
63
|
rescue StandardError => e
|
|
40
64
|
{ error: "MCP instance '#{@instance_name}' tool '#{@tool["name"]}' failed: #{e.message}" }
|
|
41
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# An MCP result arrives as a String, text plus attachments (an Array), or
|
|
70
|
+
# {error:}. Only a tool someone declared evidence for is
|
|
71
|
+
# parsed into the object the extractor can dig into; everywhere else the result
|
|
72
|
+
# reaches the model exactly as it did before.
|
|
73
|
+
def parsed(result)
|
|
74
|
+
return result unless @evidence && result.is_a?(String)
|
|
75
|
+
|
|
76
|
+
object = begin
|
|
77
|
+
JSON.parse(result)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
object.is_a?(Hash) ? object : result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def top_level_params
|
|
85
|
+
properties = parameters_schema["properties"] || parameters_schema[:properties] || {}
|
|
86
|
+
properties.keys.map(&:to_s)
|
|
87
|
+
end
|
|
42
88
|
end
|
|
43
89
|
end
|
data/lib/insika/mcp_store.rb
CHANGED
|
@@ -94,12 +94,43 @@ module Insika
|
|
|
94
94
|
"headers" => reconcile_hash(h[:headers], existing && existing["headers"]),
|
|
95
95
|
# editing an instance's connection details doesn't change its discovered
|
|
96
96
|
# tools -> preserved across upsert; only set_tools_cache writes it.
|
|
97
|
-
"tools_cache" => (existing && existing["tools_cache"]) || []
|
|
97
|
+
"tools_cache" => (existing && existing["tools_cache"]) || [],
|
|
98
|
+
# WHAT EACH TOOL MEANS to the engine: which of its results are evidence,
|
|
99
|
+
# and which of its parameters may only carry an id some tool returned.
|
|
100
|
+
# A server describes what its tools DO; only the deployment can say what
|
|
101
|
+
# they are worth trusting with. An edit that does not mention them keeps
|
|
102
|
+
# what is there — the same rule `env` and `headers` follow.
|
|
103
|
+
"tool_overrides" => (h.key?(:tools) ? tool_overrides(h[:tools]) : nil) ||
|
|
104
|
+
(existing && existing["tool_overrides"]) || {}
|
|
98
105
|
}
|
|
99
106
|
@cs.put(SCOPE, name, record)
|
|
100
107
|
mask(record)
|
|
101
108
|
end
|
|
102
109
|
|
|
110
|
+
# { "add_to_cart" => { "requires_evidence" => ["product_id"] },
|
|
111
|
+
# "search_products" => { "evidence" => { "kind" => "products", … } } }
|
|
112
|
+
#
|
|
113
|
+
# Refused HERE, never at the turn: an `evidence:` typo that survived to turn
|
|
114
|
+
# time would extract nothing and the gate below it would block every write.
|
|
115
|
+
def tool_overrides(raw)
|
|
116
|
+
return nil if raw.nil?
|
|
117
|
+
raise Insika::ValidationError, "mcp tools must be a mapping of tool name -> overrides" unless raw.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
Coercion.deep_stringify(raw).to_h do |tool, over|
|
|
120
|
+
raise Insika::ValidationError, "mcp tools['#{tool}'] must be a mapping" unless over.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
unknown = over.keys - %w[evidence requires_evidence]
|
|
123
|
+
unless unknown.empty?
|
|
124
|
+
raise Insika::ValidationError,
|
|
125
|
+
"mcp tools['#{tool}']: unknown key(s) #{unknown.join(", ")} — known: evidence, requires_evidence"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
params = Array(over["requires_evidence"]).map(&:to_s).reject(&:empty?)
|
|
129
|
+
[tool.to_s, { "evidence" => over["evidence"] && Insika::Evidence::Spec.parse(over["evidence"]).to_h,
|
|
130
|
+
"requires_evidence" => (params unless params.empty?) }.compact]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
103
134
|
# -> bool (did it exist?).
|
|
104
135
|
def delete(name) = @cs.delete(SCOPE, name.to_s)
|
|
105
136
|
|
|
@@ -135,7 +166,7 @@ module Insika
|
|
|
135
166
|
|
|
136
167
|
def http_like?(transport) = %w[http sse].include?(transport.to_s)
|
|
137
168
|
|
|
138
|
-
# -> [{"name","description","inputSchema"}] string-keyed, dropping any
|
|
169
|
+
# -> [{"name","description","inputSchema"[,"annotations"]}] string-keyed, dropping any
|
|
139
170
|
# entry without a name (nothing to register a Registry::Entry under).
|
|
140
171
|
def normalize_tools_cache(tools)
|
|
141
172
|
Array(tools).filter_map do |t|
|
|
@@ -143,7 +174,10 @@ module Insika
|
|
|
143
174
|
name = presence(h["name"])
|
|
144
175
|
next nil if name.nil?
|
|
145
176
|
|
|
146
|
-
{ "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
|
|
177
|
+
cached = { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
|
|
178
|
+
# the server's own hints (readOnlyHint decides side_effect at registration)
|
|
179
|
+
cached["annotations"] = h["annotations"] if h["annotations"].is_a?(Hash)
|
|
180
|
+
cached
|
|
147
181
|
end
|
|
148
182
|
end
|
|
149
183
|
|
|
@@ -11,14 +11,30 @@ module Insika
|
|
|
11
11
|
# `refresh` is the ONLY thing that talks to a server ahead of time: it
|
|
12
12
|
# connects, lists live, and writes the result back to tools_cache (display/
|
|
13
13
|
# doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
|
|
14
|
-
# always goes through the live, memoized client (and RubyLLM::MCP
|
|
14
|
+
# always goes through the live, memoized client (and RubyLLM::MCP
|
|
15
15
|
# does its own real `tools/list` on ITS first use per instance, regardless
|
|
16
16
|
# of whether `refresh` ever ran).
|
|
17
17
|
class McpToolRegistry
|
|
18
|
-
|
|
18
|
+
# Providers reject a tool name longer than this.
|
|
19
|
+
MAX_TOOL_NAME = 64
|
|
20
|
+
|
|
21
|
+
# The name the model and the agent's allowlist see: the instance, then the
|
|
22
|
+
# server's tool. Two servers offering the same tool (a store's prod and
|
|
23
|
+
# staging) stay two tools, each reaching its own instance.
|
|
24
|
+
def self.tool_name(instance, tool)
|
|
25
|
+
"#{instance.to_s.downcase.gsub(/[^a-z0-9]+/, '_')}__#{tool}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Live clients kept per process. Past this, the least recently used one is
|
|
29
|
+
# closed; its conversation reconnects on its next call, as a NEW MCP session.
|
|
30
|
+
# ponytail: count cap, not idle TTL — add a TTL if idle sockets pile up.
|
|
31
|
+
MAX_CLIENTS = 200
|
|
32
|
+
|
|
33
|
+
def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for), max_clients: MAX_CLIENTS)
|
|
19
34
|
@mcp_store = mcp_store
|
|
20
35
|
@client_factory = client_factory
|
|
21
|
-
@
|
|
36
|
+
@max_clients = max_clients
|
|
37
|
+
@clients = {} # [instance, conversation] -> client, oldest use first
|
|
22
38
|
@mutex = Mutex.new
|
|
23
39
|
end
|
|
24
40
|
|
|
@@ -29,7 +45,7 @@ module Insika
|
|
|
29
45
|
|
|
30
46
|
# Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
|
|
31
47
|
# ALWAYS drops any memoized client first and rebuilds from the current
|
|
32
|
-
# record: a stdio process can be
|
|
48
|
+
# record: a stdio process can still be running yet permanently
|
|
33
49
|
# broken (e.g. wrong transport args — see grafana-stg gotcha), and an
|
|
34
50
|
# edited command/url/env must take effect without a process restart —
|
|
35
51
|
# there's no SSH into a Railway dyno to do that by hand.
|
|
@@ -43,7 +59,10 @@ module Insika
|
|
|
43
59
|
|
|
44
60
|
evict(record["name"])
|
|
45
61
|
client = client_for(record)
|
|
46
|
-
discovered = client.tools.map
|
|
62
|
+
discovered = client.tools.map do |tool|
|
|
63
|
+
{ "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema,
|
|
64
|
+
"annotations" => { "readOnlyHint" => tool.read_only? } }
|
|
65
|
+
end
|
|
47
66
|
@mcp_store.set_tools_cache(name, discovered)
|
|
48
67
|
discovered
|
|
49
68
|
end
|
|
@@ -55,46 +74,77 @@ module Insika
|
|
|
55
74
|
# NEXT tool call or refresh, no process restart required (there's no way
|
|
56
75
|
# to restart a process by hand on a Railway dyno).
|
|
57
76
|
def evict(name)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
gone = @mutex.synchronize do
|
|
78
|
+
keys = @clients.keys.select { |instance, _| instance == name.to_s }
|
|
79
|
+
keys.map { |key| @clients.delete(key) }
|
|
80
|
+
end
|
|
81
|
+
gone.each { |client| close_quietly(client) }
|
|
62
82
|
end
|
|
63
83
|
|
|
64
84
|
private
|
|
65
85
|
|
|
66
86
|
def entries_for(record)
|
|
67
|
-
Array(record["tools_cache"]).
|
|
87
|
+
Array(record["tools_cache"]).filter_map do |tool|
|
|
88
|
+
name = self.class.tool_name(record["name"], tool["name"])
|
|
89
|
+
next entry_for(record, tool, name) if name.length <= MAX_TOOL_NAME
|
|
90
|
+
|
|
91
|
+
warn "[mcp] skipping '#{name}': longer than #{MAX_TOOL_NAME} characters; shorten the instance name"
|
|
92
|
+
end
|
|
68
93
|
end
|
|
69
94
|
|
|
70
|
-
def entry_for(record, tool)
|
|
95
|
+
def entry_for(record, tool, name)
|
|
71
96
|
instance = record["name"]
|
|
72
97
|
Insika::Registry::Entry.new(
|
|
73
|
-
name:
|
|
74
|
-
metadata: { optional: false, side_effect:
|
|
75
|
-
factory: -> { build_tool(record, tool) }
|
|
98
|
+
name: name, plugin: "mcp:#{instance}",
|
|
99
|
+
metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
|
|
100
|
+
factory: -> { build_tool(record, tool, name) }
|
|
76
101
|
)
|
|
77
102
|
end
|
|
78
103
|
|
|
104
|
+
# An MCP tool is a side effect unless its server says otherwise
|
|
105
|
+
# (`annotations.readOnlyHint`): a write is never re-run on resume and runs
|
|
106
|
+
# serially within the session; a declared read keeps `tool_concurrency`.
|
|
107
|
+
def read_only?(tool)
|
|
108
|
+
tool.dig("annotations", "readOnlyHint") == true
|
|
109
|
+
end
|
|
110
|
+
|
|
79
111
|
# Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
|
|
80
112
|
# of insika.rb load-time, loaded on the 1st instance (turn time), same
|
|
81
113
|
# discipline as OverlayToolRegistry#build_tool for data-tools.
|
|
82
|
-
def build_tool(record, tool)
|
|
114
|
+
def build_tool(record, tool, name)
|
|
83
115
|
require_relative "mcp_live_tool"
|
|
84
|
-
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool,
|
|
116
|
+
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, name: name,
|
|
117
|
+
overrides: record.dig("tool_overrides", tool["name"]) || {},
|
|
118
|
+
client_for: ->(session_id = nil) { client_for(record, session_id) })
|
|
85
119
|
end
|
|
86
120
|
|
|
87
|
-
# A
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
# A lazy, MEMOIZED client for `record` — one per (instance, conversation),
|
|
122
|
+
# reused across that conversation's turns. The server ties its state to the
|
|
123
|
+
# MCP session (Mcp-Session-Id): a client shared by every conversation leaked
|
|
124
|
+
# one chat's target store and confirmation tokens into another. A stdio
|
|
125
|
+
# instance stays one shared process — a process per chat is too costly.
|
|
126
|
+
# Raises on a gated/unreachable instance; only called from `refresh` and
|
|
127
|
+
# from a running McpLiveTool's `#execute` (which rescues) — never from
|
|
128
|
+
# `entries`/`build_tool`, so a downed server never breaks turn ASSEMBLY,
|
|
129
|
+
# only that tool's own call.
|
|
130
|
+
def client_for(record, session_id = nil)
|
|
131
|
+
session_id = nil if record["transport"].to_s == "stdio"
|
|
132
|
+
key = [record["name"], session_id&.to_s]
|
|
133
|
+
stale = nil
|
|
134
|
+
client = @mutex.synchronize do
|
|
135
|
+
found = @clients.delete(key) || @client_factory.call(record)
|
|
136
|
+
@clients[key] = found # re-insert: most recently used goes last
|
|
137
|
+
stale = @clients.shift.last if @clients.size > @max_clients
|
|
138
|
+
found
|
|
97
139
|
end
|
|
140
|
+
close_quietly(stale) if stale
|
|
141
|
+
client
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def close_quietly(client)
|
|
145
|
+
client.close
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil # best-effort teardown of a possibly already-dead process
|
|
98
148
|
end
|
|
99
149
|
end
|
|
100
150
|
end
|
data/lib/insika/media.rb
CHANGED
|
@@ -93,9 +93,9 @@ module Insika
|
|
|
93
93
|
# when the turn first carries audio. `stt_prompt` is the Whisper-family
|
|
94
94
|
# vocabulary hint (product names, brand terms) — OPERATOR config
|
|
95
95
|
# (agent profile / deployment env), never customer input.
|
|
96
|
-
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
|
|
96
|
+
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil, context: nil)
|
|
97
97
|
lambda do |url|
|
|
98
|
-
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
|
|
98
|
+
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt, context: context)
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
@@ -110,7 +110,7 @@ module Insika
|
|
|
110
110
|
# RubyLLM raises ArgumentError when it's true without an explicit
|
|
111
111
|
# `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
|
|
112
112
|
# is a bare ref like `utility_model` elsewhere — the registry resolves it.
|
|
113
|
-
def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
|
|
113
|
+
def self.fetch_and_transcribe(url, model:, language:, prompt: nil, context: nil)
|
|
114
114
|
require "net/http"
|
|
115
115
|
require "uri"
|
|
116
116
|
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
@@ -120,6 +120,7 @@ module Insika
|
|
|
120
120
|
options = { model: model }
|
|
121
121
|
options[:language] = language if language
|
|
122
122
|
options[:prompt] = prompt if prompt
|
|
123
|
+
options[:context] = context if context
|
|
123
124
|
Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
|
|
124
125
|
file.binmode
|
|
125
126
|
file.write(bytes)
|
|
@@ -128,6 +129,23 @@ module Insika
|
|
|
128
129
|
end
|
|
129
130
|
end
|
|
130
131
|
|
|
132
|
+
# Durable attachment bytes from a checkpoint; hydrate only at the gem boundary.
|
|
133
|
+
def self.hydrate_attachment(value)
|
|
134
|
+
return value unless value.is_a?(Hash)
|
|
135
|
+
require 'base64'
|
|
136
|
+
require 'stringio'
|
|
137
|
+
require 'ruby_llm'
|
|
138
|
+
unless (value.keys - %w[base64 filename]).empty? && value['base64'].is_a?(String) && value['filename'].is_a?(String)
|
|
139
|
+
raise Insika::MediaError, 'invalid stored attachment'
|
|
140
|
+
end
|
|
141
|
+
raise Insika::MediaError, 'stored attachment too large' if value['base64'].bytesize > 14*1024*1024
|
|
142
|
+
bytes = Base64.strict_decode64(value['base64'])
|
|
143
|
+
raise Insika::MediaError, 'stored attachment too large' if bytes.bytesize > MAX_DOCUMENT_BYTES
|
|
144
|
+
RubyLLM::Attachment.new(StringIO.new(bytes), filename: value['filename'])
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
raise Insika::MediaError, 'invalid stored attachment'
|
|
147
|
+
end
|
|
148
|
+
|
|
131
149
|
# An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
|
|
132
150
|
# guarded, size-capped — the `media_attachment` recipe). Shared by the
|
|
133
151
|
# Executor (inbound image/document parts) and `Output.generate_image`
|
|
@@ -201,8 +219,7 @@ module Insika
|
|
|
201
219
|
# behind lazy requires:
|
|
202
220
|
# · image — RubyLLM.paint (the gem has vision AND painting), billed
|
|
203
221
|
# tokens merged into the turn's usage like any ask;
|
|
204
|
-
# · tts —
|
|
205
|
-
# is a thin POST to the OpenAI-compatible `<base>/audio/speech`
|
|
222
|
+
# · tts — a capped POST to the OpenAI-compatible `<base>/audio/speech`
|
|
206
223
|
# endpoint (base + key from the provider config the chat uses — a
|
|
207
224
|
# deployment pointing OpenAI at a gateway keeps TTS pointing there).
|
|
208
225
|
# OpenAI's speech API reports no token usage; the part carries the
|
|
@@ -256,8 +273,8 @@ module Insika
|
|
|
256
273
|
|
|
257
274
|
enforce_embedded_size!(data, "generated image")
|
|
258
275
|
mime = image.respond_to?(:mime_type) ? image.mime_type : nil
|
|
259
|
-
model_id = image.
|
|
260
|
-
usage = image.
|
|
276
|
+
model_id = image.model
|
|
277
|
+
usage = { input_tokens: image.tokens.input, output_tokens: image.tokens.output }.compact
|
|
261
278
|
part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
|
|
262
279
|
"base64" => data, "model" => presence(model_id) }
|
|
263
280
|
[part.compact, usage]
|
|
@@ -292,20 +309,24 @@ module Insika
|
|
|
292
309
|
req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
|
|
293
310
|
response_format: format)
|
|
294
311
|
opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
|
|
312
|
+
# RubyLLM 2.0 buffers non-audio/error responses without yielding chunks.
|
|
313
|
+
# Keep this bounded transport until native speech caps every response.
|
|
295
314
|
bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
296
|
-
resp = http.request(req)
|
|
297
|
-
raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
298
|
-
|
|
299
|
-
# stream into the cap — a rogue/broken endpoint must not grow the
|
|
300
|
-
# process past MAX_EMBEDDED_BYTES before the refusal.
|
|
301
315
|
buf = +"".b
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
316
|
+
http.request(req) do |resp|
|
|
317
|
+
raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
318
|
+
|
|
319
|
+
resp.read_body do |chunk|
|
|
320
|
+
size = buf.bytesize + chunk.bytesize
|
|
321
|
+
if size > MAX_EMBEDDED_BYTES
|
|
322
|
+
raise Insika::MediaError,
|
|
323
|
+
"synthesized speech too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
|
|
324
|
+
end
|
|
325
|
+
buf << chunk
|
|
326
|
+
end
|
|
305
327
|
end
|
|
306
328
|
buf
|
|
307
329
|
end
|
|
308
|
-
enforce_embedded_size!(bytes, "synthesized speech")
|
|
309
330
|
part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
|
|
310
331
|
[part.compact, {}]
|
|
311
332
|
rescue URI::InvalidURIError
|
|
@@ -352,14 +373,6 @@ module Insika
|
|
|
352
373
|
Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
|
|
353
374
|
end
|
|
354
375
|
|
|
355
|
-
def token_usage(raw)
|
|
356
|
-
usage = raw.is_a?(Hash) ? raw : {}
|
|
357
|
-
{
|
|
358
|
-
input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
|
|
359
|
-
output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
|
|
360
|
-
}.compact
|
|
361
|
-
end
|
|
362
|
-
|
|
363
376
|
def mime_for(format)
|
|
364
377
|
{ "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
|
|
365
378
|
"wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
|
|
@@ -379,4 +392,4 @@ module Insika
|
|
|
379
392
|
end
|
|
380
393
|
end
|
|
381
394
|
end
|
|
382
|
-
end
|
|
395
|
+
end
|
data/lib/insika/memory_store.rb
CHANGED
|
@@ -42,6 +42,18 @@ module Insika
|
|
|
42
42
|
# ids live in a different namespace in practice.
|
|
43
43
|
SESSION_TAG = "chat"
|
|
44
44
|
|
|
45
|
+
# The ONE memory-cell rule, read by the turn (Executor) and by whoever
|
|
46
|
+
# writes a cell the turn must find (SeedSession): a customer -> the
|
|
47
|
+
# "[tenant:]customer" cell; otherwise the tenant's cell; otherwise the marked
|
|
48
|
+
# per-session cell "chat:<session id>" (nil for a one-shot turn with no
|
|
49
|
+
# session — the store applies _default).
|
|
50
|
+
def self.scope_for(tenant:, customer:, session_id:)
|
|
51
|
+
return [tenant, customer].compact.join(":") if customer
|
|
52
|
+
return tenant if tenant
|
|
53
|
+
|
|
54
|
+
"#{SESSION_TAG}:#{session_id}" if session_id
|
|
55
|
+
end
|
|
56
|
+
|
|
45
57
|
Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
|
|
46
58
|
Note = Data.define(:id, :text, :created_at)
|
|
47
59
|
|
|
@@ -143,9 +155,10 @@ module Insika
|
|
|
143
155
|
# "memory:chat:<tenant>:<id>"). The scope enumeration is the Store's — no
|
|
144
156
|
# session-derived list, so a customer cell whose session was already
|
|
145
157
|
# deleted is still purged. ONE transaction for the whole tenant (the repo
|
|
146
|
-
# rule): the
|
|
147
|
-
#
|
|
148
|
-
#
|
|
158
|
+
# rule): the purge commits or fails whole. On SQLite, whose transactions
|
|
159
|
+
# run one at a time, a cell cannot be written mid-purge at all; on a backend
|
|
160
|
+
# with concurrent writers a cell written while the purge runs lands after it
|
|
161
|
+
# (a later write, which a later purge removes). -> count of records purged.
|
|
149
162
|
def purge_tenant(tenant)
|
|
150
163
|
cell = scope_for(tenant)
|
|
151
164
|
@store.transaction do
|