insika 0.3.0 → 0.7.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 +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# LIVE MCP tools — kills the McpToolIngestor snapshot.
|
|
5
|
+
#
|
|
6
|
+
# `entries` is CHEAP: it reads only McpStore#tools_cache (no I/O), the same
|
|
7
|
+
# contract the ToolStore-backed dynamic entries already give
|
|
8
|
+
# OverlayToolRegistry — Policy recomputes `candidate_tools` every turn, so
|
|
9
|
+
# an unreachable MCP server must never add real latency there.
|
|
10
|
+
#
|
|
11
|
+
# `refresh` is the ONLY thing that talks to a server ahead of time: it
|
|
12
|
+
# connects, lists live, and writes the result back to tools_cache (display/
|
|
13
|
+
# doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
|
|
14
|
+
# always goes through the live, memoized client (and RubyLLM::MCP::Client
|
|
15
|
+
# does its own real `tools/list` on ITS first use per instance, regardless
|
|
16
|
+
# of whether `refresh` ever ran).
|
|
17
|
+
class McpToolRegistry
|
|
18
|
+
def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for))
|
|
19
|
+
@mcp_store = mcp_store
|
|
20
|
+
@client_factory = client_factory
|
|
21
|
+
@clients = {}
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# -> [Registry::Entry] one per cached tool of every ENABLED instance.
|
|
26
|
+
def entries
|
|
27
|
+
@mcp_store.all_raw.select { |r| r["enabled"] }.flat_map { |r| entries_for(r) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
|
|
31
|
+
# ALWAYS drops any memoized client first and rebuilds from the current
|
|
32
|
+
# record: a stdio process can be `alive?` (still running) yet permanently
|
|
33
|
+
# broken (e.g. wrong transport args — see grafana-stg gotcha), and an
|
|
34
|
+
# edited command/url/env must take effect without a process restart —
|
|
35
|
+
# there's no SSH into a Railway dyno to do that by hand.
|
|
36
|
+
# -> the discovered [{"name","description","inputSchema"}]. Raises on a
|
|
37
|
+
# missing/disabled instance or a transport failure — the caller (a CLI
|
|
38
|
+
# verb/API route/UI button, PR3/PR4) decides how to surface it.
|
|
39
|
+
def refresh(name)
|
|
40
|
+
record = @mcp_store.get_raw(name.to_s)
|
|
41
|
+
raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
|
|
42
|
+
raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]
|
|
43
|
+
|
|
44
|
+
evict(record["name"])
|
|
45
|
+
client = client_for(record)
|
|
46
|
+
discovered = client.tools.map { |t| { "name" => t.name, "description" => t.description, "inputSchema" => t.params_schema } }
|
|
47
|
+
@mcp_store.set_tools_cache(name, discovered)
|
|
48
|
+
discovered
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stops and drops any memoized client for `name` (no-op if none), so the
|
|
52
|
+
# next `client_for` builds a fresh one off the current record. Public:
|
|
53
|
+
# called by `refresh` above AND by UpsertMcp/DeleteMcp right after they
|
|
54
|
+
# write the store — a Studio/CLI edit takes effect on that instance's
|
|
55
|
+
# NEXT tool call or refresh, no process restart required (there's no way
|
|
56
|
+
# to restart a process by hand on a Railway dyno).
|
|
57
|
+
def evict(name)
|
|
58
|
+
client = @mutex.synchronize { @clients.delete(name.to_s) }
|
|
59
|
+
client&.stop
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil # best-effort teardown of a possibly already-dead process
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def entries_for(record)
|
|
67
|
+
Array(record["tools_cache"]).map { |tool| entry_for(record, tool) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def entry_for(record, tool)
|
|
71
|
+
instance = record["name"]
|
|
72
|
+
Insika::Registry::Entry.new(
|
|
73
|
+
name: tool["name"], plugin: "mcp:#{instance}",
|
|
74
|
+
metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
|
|
75
|
+
factory: -> { build_tool(record, tool) }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
|
|
80
|
+
# of insika.rb load-time, loaded on the 1st instance (turn time), same
|
|
81
|
+
# discipline as OverlayToolRegistry#build_tool for data-tools.
|
|
82
|
+
def build_tool(record, tool)
|
|
83
|
+
require_relative "mcp_live_tool"
|
|
84
|
+
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, client_for: -> { client_for(record) })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A started, MEMOIZED client for `record` — one real connection per
|
|
88
|
+
# instance name, reused across calls/turns. Raises on a gated/unreachable
|
|
89
|
+
# instance; only called from `refresh` and from a running McpLiveTool's
|
|
90
|
+
# `#execute` (which rescues) — never from `entries`/`build_tool`, so a
|
|
91
|
+
# downed server never breaks turn ASSEMBLY, only that tool's own call.
|
|
92
|
+
def client_for(record)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
client = (@clients[record["name"]] ||= @client_factory.call(record))
|
|
95
|
+
client.start unless client.alive?
|
|
96
|
+
client
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/insika/media.rb
CHANGED
|
@@ -3,28 +3,31 @@
|
|
|
3
3
|
module Insika
|
|
4
4
|
# WS9: the engine transports MEDIA, never meaning. Content parts ride the
|
|
5
5
|
# message contract — `{ "type": "text", "text": … }`, `{ "type": "image",
|
|
6
|
-
# "url": … }`, `{ "type": "audio", "url": … }`
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# (
|
|
15
|
-
# (`
|
|
6
|
+
# "url": … }`, `{ "type": "audio", "url": … }`, `{ "type": "document",
|
|
7
|
+
# "url": … }` — and the Executor turns them into a turn: audio is
|
|
8
|
+
# transcribed (text marked `source: :voice`), images and documents attach
|
|
9
|
+
# to the model ask and the first URL of each kind is `{{ctx.image_url}}` /
|
|
10
|
+
# `{{ctx.document_url}}` for data tools. This class owns the PURE parts
|
|
11
|
+
# (normalization) and the STT SEAM (injectable — specs stub it; the default
|
|
12
|
+
# fetches the audio and transcribes via RubyLLM behind a lazy require, so
|
|
13
|
+
# the core stays gem-free at load). `Media::Output` is the generated-media
|
|
14
|
+
# half (WS9, saída): the turn can PRODUCE an image or an audio clip when the
|
|
15
|
+
# agent opted in (`AgentProfile#outputs`) AND the channel declared it can
|
|
16
|
+
# receive it (`channel.capabilities`) — nothing leaks by default.
|
|
16
17
|
module Media
|
|
17
18
|
# A single content part, normalized.
|
|
18
19
|
Part = Data.define(:type, :text, :url) do
|
|
19
20
|
def audio? = type == "audio"
|
|
20
21
|
def image? = type == "image"
|
|
22
|
+
def document? = type == "document"
|
|
21
23
|
def text? = type == "text"
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
# -> [Part]: normalize the raw parts (string|symbol keys), skipping anything
|
|
25
|
-
# that is not a well-formed text/image/audio part. Lenient on
|
|
26
|
-
# SURFACE validates the contract with `well_formed?` (a
|
|
27
|
-
# 422 before dispatch); here a stray entry must not
|
|
27
|
+
# that is not a well-formed text/image/audio/document part. Lenient on
|
|
28
|
+
# purpose — the SURFACE validates the contract with `well_formed?` (a
|
|
29
|
+
# malformed part is a 422 before dispatch); here a stray entry must not
|
|
30
|
+
# break the turn.
|
|
28
31
|
def self.parts(raw)
|
|
29
32
|
Array(raw).filter_map do |p|
|
|
30
33
|
next unless p.is_a?(Hash)
|
|
@@ -34,24 +37,24 @@ module Insika
|
|
|
34
37
|
text = (p[:text] || p["text"]).to_s
|
|
35
38
|
case type
|
|
36
39
|
when "text" then text.empty? ? nil : Part.new("text", text, nil)
|
|
37
|
-
when "image", "audio" then url.empty? ? nil : Part.new(type, nil, url)
|
|
40
|
+
when "image", "audio", "document" then url.empty? ? nil : Part.new(type, nil, url)
|
|
38
41
|
else nil
|
|
39
42
|
end
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
# The SURFACE's contract check (server edge): true when EVERY entry is a
|
|
44
|
-
# well-formed content part — a Hash whose type is text (with text), image
|
|
45
|
-
# or
|
|
46
|
-
# engine itself stays lenient (`parts` skips strays so a
|
|
47
|
-
# that bypassed the edge cannot break a turn).
|
|
47
|
+
# well-formed content part — a Hash whose type is text (with text), image,
|
|
48
|
+
# audio or document (with url). The edge raises a 422 on the first
|
|
49
|
+
# offender; the engine itself stays lenient (`parts` skips strays so a
|
|
50
|
+
# non-HTTP transport that bypassed the edge cannot break a turn).
|
|
48
51
|
def self.well_formed?(raw)
|
|
49
52
|
Array(raw).all? do |p|
|
|
50
53
|
next false unless p.is_a?(Hash)
|
|
51
54
|
|
|
52
55
|
case (p[:type] || p["type"]).to_s
|
|
53
56
|
when "text" then !(p[:text] || p["text"]).to_s.empty?
|
|
54
|
-
when "image", "audio" then !(p[:url] || p["url"]).to_s.empty?
|
|
57
|
+
when "image", "audio", "document" then !(p[:url] || p["url"]).to_s.empty?
|
|
55
58
|
# a part WITHOUT a type is admitted only as a bare text part (the
|
|
56
59
|
# shape the input joiner already tolerates) — anything else is refused.
|
|
57
60
|
when "" then !(p[:text] || p["text"]).to_s.empty?
|
|
@@ -79,29 +82,75 @@ module Insika
|
|
|
79
82
|
# so an uncapped one is a hostile URL away from growing it until it dies.
|
|
80
83
|
MAX_AUDIO_BYTES = 1_000_000 # a voice note, not a warehouse
|
|
81
84
|
MAX_IMAGE_BYTES = 5_000_000 # a photo, not a poster
|
|
85
|
+
MAX_DOCUMENT_BYTES = 10_000_000 # a prescription, not an archive
|
|
82
86
|
|
|
83
87
|
def self.audio_parts(parts) = parts.select(&:audio?)
|
|
84
88
|
def self.image_parts(parts) = parts.select(&:image?)
|
|
89
|
+
def self.document_parts(parts) = parts.select(&:document?)
|
|
85
90
|
|
|
86
91
|
# The STT seam: ->(url) { text } (default: fetch + RubyLLM transcription).
|
|
87
92
|
# Injected so a spec never touches the network; the default is built lazily
|
|
88
|
-
# when the turn first carries audio.
|
|
89
|
-
|
|
93
|
+
# when the turn first carries audio. `stt_prompt` is the Whisper-family
|
|
94
|
+
# vocabulary hint (product names, brand terms) — OPERATOR config
|
|
95
|
+
# (agent profile / deployment env), never customer input.
|
|
96
|
+
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
|
|
90
97
|
lambda do |url|
|
|
91
|
-
fetch_and_transcribe(url, model: stt_model, language: stt_language)
|
|
98
|
+
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
|
|
92
99
|
end
|
|
93
100
|
end
|
|
94
101
|
|
|
95
|
-
|
|
102
|
+
# A file PATH, not bytes and not an Attachment: `RubyLLM::Transcription.
|
|
103
|
+
# transcribe` hands its argument to the PROVIDER's own `transcribe`, and the
|
|
104
|
+
# two shapes in this gem disagree — Gemini wraps it in `Attachment.new`
|
|
105
|
+
# itself (a raw byte String there is misread as a Pathname and blows up on
|
|
106
|
+
# any embedded null byte, which real audio has), while the DEFAULT
|
|
107
|
+
# `Provider#transcribe` (OpenAI, Mistral) calls `File.expand_path` on it
|
|
108
|
+
# directly and cannot take bytes/IO/Attachment at all. A tempfile is the
|
|
109
|
+
# one shape both accept. `assume_model_exists` is deliberately NOT passed:
|
|
110
|
+
# RubyLLM raises ArgumentError when it's true without an explicit
|
|
111
|
+
# `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
|
|
112
|
+
# is a bare ref like `utility_model` elsewhere — the registry resolves it.
|
|
113
|
+
def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
|
|
96
114
|
require "net/http"
|
|
97
115
|
require "uri"
|
|
98
116
|
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
117
|
+
require "tempfile"
|
|
99
118
|
|
|
100
119
|
bytes = fetch_binary(url)
|
|
101
|
-
|
|
102
|
-
options = { model: model, assume_model_exists: true }
|
|
120
|
+
options = { model: model }
|
|
103
121
|
options[:language] = language if language
|
|
104
|
-
|
|
122
|
+
options[:prompt] = prompt if prompt
|
|
123
|
+
Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
|
|
124
|
+
file.binmode
|
|
125
|
+
file.write(bytes)
|
|
126
|
+
file.flush
|
|
127
|
+
RubyLLM::Transcription.transcribe(file.path, **options).text
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
|
|
132
|
+
# guarded, size-capped — the `media_attachment` recipe). Shared by the
|
|
133
|
+
# Executor (inbound image/document parts) and `Output.generate_image`
|
|
134
|
+
# (edit sources / mask): an io-like source (StringIO) is the branch of
|
|
135
|
+
# Attachment that takes bytes already held, so the provider gets base64
|
|
136
|
+
# rather than the URL — handing the raw URL to RubyLLM instead would leave
|
|
137
|
+
# the (uncapped) fetch to the gem.
|
|
138
|
+
def self.url_attachment(url, max_bytes: MAX_IMAGE_BYTES)
|
|
139
|
+
require "ruby_llm"
|
|
140
|
+
require "stringio"
|
|
141
|
+
|
|
142
|
+
bytes = fetch_binary(url, max_bytes: max_bytes)
|
|
143
|
+
RubyLLM::Attachment.new(StringIO.new(bytes), filename: filename_for(url))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The URL's basename, for the attachment's mime sniff (".png" -> image/png;
|
|
147
|
+
# a URL with no filename falls back to the content sniff RubyLLM does).
|
|
148
|
+
def self.filename_for(url)
|
|
149
|
+
require "uri"
|
|
150
|
+
name = File.basename(URI.parse(url).path.to_s)
|
|
151
|
+
name.empty? ? nil : name
|
|
152
|
+
rescue URI::InvalidURIError
|
|
153
|
+
nil
|
|
105
154
|
end
|
|
106
155
|
|
|
107
156
|
# Egress-guarded binary fetch of a media URL. Blocked like the webhook: the
|
|
@@ -166,6 +215,9 @@ module Insika
|
|
|
166
215
|
# Base64 inlines into the envelope — a cap so a pathological generation
|
|
167
216
|
# cannot blow up the SSE frame. A generated 1024x1024 PNG sits well under.
|
|
168
217
|
MAX_EMBEDDED_BYTES = 8 * 1024 * 1024
|
|
218
|
+
# Edit sources on one `paint(with:)` call — a fitting room needs a
|
|
219
|
+
# handful of angles, not a gallery.
|
|
220
|
+
MAX_SOURCE_IMAGES = 4
|
|
169
221
|
|
|
170
222
|
class << self
|
|
171
223
|
# -> { image: seam, tts: seam } with the DEFAULTS bound to a context
|
|
@@ -179,17 +231,26 @@ module Insika
|
|
|
179
231
|
}
|
|
180
232
|
end
|
|
181
233
|
|
|
182
|
-
# -> [Part, usage]: paint via RubyLLM
|
|
183
|
-
#
|
|
184
|
-
#
|
|
234
|
+
# -> [Part, usage]: paint via RubyLLM — text-to-image when the config
|
|
235
|
+
# carries no sources (byte-identical to before this call), image
|
|
236
|
+
# EDITING when it does: `source_urls`/`source_attachments` ride
|
|
237
|
+
# `paint(with:)`, `mask_url` rides `paint(mask:)`. usage is the
|
|
238
|
+
# provider's token counts ({ input_tokens:, output_tokens: } — merged
|
|
239
|
+
# into the turn's usage by the Executor); a provider without counts
|
|
240
|
+
# reports nothing.
|
|
185
241
|
def generate_image(prompt, config:, context:)
|
|
186
242
|
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
187
243
|
|
|
188
244
|
cfg = Insika::Coercion.deep_stringify(config || {})
|
|
189
245
|
model = Insika::Coercion.presence(cfg["model"]) || image_model(context)
|
|
246
|
+
# `assume_model_exists` is deliberately NOT passed: RubyLLM raises
|
|
247
|
+
# ArgumentError when it's true without an explicit `provider` (see
|
|
248
|
+
# ModelSelection#assume_model_exists?), and `model` here is a bare
|
|
249
|
+
# ref like `utility_model` elsewhere — the registry resolves it.
|
|
190
250
|
api = context || RubyLLM
|
|
191
|
-
image = api.paint(prompt.to_s, model: model,
|
|
192
|
-
size: presence(cfg["size"]) || DEFAULT_IMAGE_SIZE
|
|
251
|
+
image = api.paint(prompt.to_s, model: model,
|
|
252
|
+
size: presence(cfg["size"]) || DEFAULT_IMAGE_SIZE,
|
|
253
|
+
with: source_attachments(cfg), mask: mask_attachment(cfg))
|
|
193
254
|
data = image.respond_to?(:data) ? image.data : nil
|
|
194
255
|
raise Insika::MediaError, "image generation returned no embeddable data" if data.to_s.empty?
|
|
195
256
|
|
|
@@ -268,6 +329,29 @@ module Insika
|
|
|
268
329
|
config.respond_to?(:default_image_model) ? config.default_image_model : nil
|
|
269
330
|
end
|
|
270
331
|
|
|
332
|
+
# -> [Attachment] | nil: the edit sources for `paint(with:)`. Pre-built
|
|
333
|
+
# attachments win (the tool's DEFAULT source — the turn's own inbound
|
|
334
|
+
# images, already fetched bytes, no URL round-trip); otherwise explicit
|
|
335
|
+
# `source_urls` are fetched through the SAME capped, egress-guarded
|
|
336
|
+
# path images always used. nil (never []) when there are none — a
|
|
337
|
+
# provider without OpenAI's `editing?` leniency raises on a non-nil
|
|
338
|
+
# `with:`, and a text-to-image turn must stay byte-identical.
|
|
339
|
+
def source_attachments(cfg)
|
|
340
|
+
built = cfg["source_attachments"]
|
|
341
|
+
return built if built.is_a?(Array) && built.any?
|
|
342
|
+
|
|
343
|
+
urls = Array(cfg["source_urls"]).map(&:to_s).reject(&:empty?).first(MAX_SOURCE_IMAGES)
|
|
344
|
+
urls.empty? ? nil : urls.map { |u| Insika::Media.url_attachment(u, max_bytes: Insika::Media::MAX_IMAGE_BYTES) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# -> Attachment | nil: the optional edit mask for `paint(mask:)`.
|
|
348
|
+
def mask_attachment(cfg)
|
|
349
|
+
url = presence(cfg["mask_url"])
|
|
350
|
+
return nil unless url
|
|
351
|
+
|
|
352
|
+
Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
|
|
353
|
+
end
|
|
354
|
+
|
|
271
355
|
def token_usage(raw)
|
|
272
356
|
usage = raw.is_a?(Hash) ? raw : {}
|
|
273
357
|
{
|
|
@@ -18,7 +18,7 @@ module Insika
|
|
|
18
18
|
# Reading a transcript without that distinction is not a rounding error. The first
|
|
19
19
|
# refinement run over real traffic reported `repetition ×219` on one agent: every
|
|
20
20
|
# one of them the engine reading its own injected fragment back and calling it a
|
|
21
|
-
# customer repeating themselves
|
|
21
|
+
# customer repeating themselves. That was filtered by a REGEX on the
|
|
22
22
|
# leading tag, labelled in the code as a heuristic standing in for this field.
|
|
23
23
|
#
|
|
24
24
|
# ABSENT is the common case and stays valid forever: a message with no origin is
|
data/lib/insika/middleware.rb
CHANGED
|
@@ -26,6 +26,15 @@ module Insika
|
|
|
26
26
|
@middlewares = middlewares
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Appends a link at the END (innermost). This is the plugin Loader's
|
|
30
|
+
# registration seam ("collections that respond to <<") — it runs at boot,
|
|
31
|
+
# single-fiber, before the server accepts connections, so appending here
|
|
32
|
+
# never races a turn.
|
|
33
|
+
def <<(middleware)
|
|
34
|
+
@middlewares << middleware
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def call(state, &terminal)
|
|
30
39
|
chain = @middlewares.reverse.reduce(terminal) do |nxt, mw|
|
|
31
40
|
proc { |s| mw.call(s, &nxt) }
|
data/lib/insika/onboarding.rb
CHANGED
|
@@ -38,29 +38,45 @@ module Insika
|
|
|
38
38
|
"readme" => "README.md",
|
|
39
39
|
"why" => "docs/WHY.md",
|
|
40
40
|
"agents" => "docs/AGENTS.md",
|
|
41
|
+
"policy" => "docs/POLICY.md",
|
|
41
42
|
"tools" => "docs/TOOLS.md",
|
|
42
43
|
"skills" => "docs/SKILLS.md",
|
|
43
44
|
"context" => "docs/CONTEXT.md",
|
|
44
45
|
"workflows" => "docs/WORKFLOWS.md",
|
|
45
46
|
"channels" => "docs/CHANNELS.md",
|
|
46
47
|
"plugins" => "docs/PLUGINS.md",
|
|
48
|
+
"templates" => "docs/TEMPLATES.md",
|
|
49
|
+
"media" => "docs/MEDIA.md",
|
|
47
50
|
"security" => "docs/SECURITY.md",
|
|
48
51
|
"architecture" => "docs/ARCHITECTURE.md",
|
|
49
52
|
"running-local" => "docs/RUNNING-LOCAL.md",
|
|
50
53
|
"deploy" => "docs/DEPLOY.md",
|
|
54
|
+
"router" => "docs/ROUTER.md",
|
|
51
55
|
"embedding" => "docs/EMBEDDING.md",
|
|
52
56
|
"sandbox" => "docs/SANDBOX.md",
|
|
53
57
|
"benchmark" => "docs/BENCHMARK.md",
|
|
54
58
|
"observability" => "docs/OBSERVABILITY.md",
|
|
59
|
+
"schedules" => "docs/SCHEDULING.md",
|
|
60
|
+
"artifacts" => "docs/ARTIFACTS.md",
|
|
61
|
+
"soak" => "docs/SOAK.md",
|
|
55
62
|
"evals" => "docs/EVALS.md",
|
|
56
63
|
"refinement" => "docs/REFINEMENT.md",
|
|
64
|
+
"outcomes" => "docs/OUTCOMES.md",
|
|
57
65
|
"facts" => "docs/FACTS.md",
|
|
58
66
|
"harvest" => "docs/HARVEST.md",
|
|
67
|
+
"knowledge" => "docs/KNOWLEDGE.md",
|
|
59
68
|
"loadtest" => "docs/LOADTEST.md",
|
|
60
69
|
"releasing" => "docs/RELEASING.md",
|
|
70
|
+
"demo" => "docs/DEMO.md",
|
|
61
71
|
# the /v1 compatibility contract and the domain-free map.
|
|
62
72
|
"api" => "docs/API.md",
|
|
63
|
-
"domain" => "docs/domain.md"
|
|
73
|
+
"domain" => "docs/domain.md",
|
|
74
|
+
# copy-paste journeys for the developer's coding agent (README "Or let your
|
|
75
|
+
# coding agent build it").
|
|
76
|
+
"run-examples" => "docs/prompts/RUN-EXAMPLES.md",
|
|
77
|
+
"add-tool" => "docs/prompts/ADD-TOOL.md",
|
|
78
|
+
"diagnose-turn" => "docs/prompts/DIAGNOSE-TURN.md",
|
|
79
|
+
"go-live" => "docs/prompts/GO-LIVE.md"
|
|
64
80
|
}.freeze
|
|
65
81
|
|
|
66
82
|
# Repo-relative path to the start.md template.
|
data/lib/insika/outcome_store.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Insika
|
|
|
14
14
|
#
|
|
15
15
|
# The Studio's scorecard is the consumer: the LAST outcome per agent as a
|
|
16
16
|
# pill on the grid, and the per-day series on the agent detail — both fed
|
|
17
|
-
# from the same store,
|
|
17
|
+
# from the same store, read on demand.
|
|
18
18
|
class OutcomeStore
|
|
19
19
|
SCOPE = "outcomes"
|
|
20
20
|
|
|
@@ -2,47 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
4
|
# DYNAMIC tool registry: composes the CODE registry (base, built at boot,
|
|
5
|
-
# immutable)
|
|
6
|
-
# the
|
|
5
|
+
# immutable), the DATA-DEFINED tools from the ToolStore, and — optionally —
|
|
6
|
+
# the LIVE MCP tools from Insika::McpToolRegistry. Drop-in
|
|
7
|
+
# for ToolRegistry — the Executor/ToolCatalog/ToolEnvelope only use
|
|
8
|
+
# entries/resolve/side_effect?.
|
|
7
9
|
#
|
|
8
10
|
# Rules:
|
|
9
|
-
# - COLLISION: the base (code) ALWAYS wins — a data-tool
|
|
10
|
-
# the name of a code tool (security, R3)
|
|
11
|
+
# - COLLISION: the base (code) ALWAYS wins — a data-tool or an MCP tool
|
|
12
|
+
# cannot hijack the name of a code tool (security, R3); a data-tool
|
|
13
|
+
# also wins over an MCP tool of the same name (an operator-authored
|
|
14
|
+
# definition over a server's own naming). The authoring Command also
|
|
11
15
|
# refuses to create with a colliding name (code_tool?), but the defense stays here.
|
|
12
16
|
# - HOT: `reload` re-reads the store and swaps the dynamic index atomically — a
|
|
13
17
|
# new/edited data-tool takes effect on the next turn without a restart, mirroring
|
|
14
|
-
# SkillCatalog.reload. An in-flight turn has already captured the index.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
18
|
+
# SkillCatalog.reload. An in-flight turn has already captured the index. MCP
|
|
19
|
+
# entries need no such reload — they read McpStore#tools_cache fresh every call
|
|
20
|
+
# (no I/O, so there is nothing to memoize-then-invalidate).
|
|
21
|
+
# PARITY: empty ToolStore + no mcp_registry ⇒ entries/resolve/side_effect? identical
|
|
22
|
+
# to the pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
|
|
17
23
|
#
|
|
18
|
-
# The data-tools enter as NORMAL Registry::Entry (optional:
|
|
19
|
-
# the same per-agent allow/deny as code tools; exposure
|
|
20
|
-
# (the /tools matrix), not automatic just because they
|
|
24
|
+
# The data-tools and MCP tools enter as NORMAL Registry::Entry (optional:
|
|
25
|
+
# false) — they obey the same per-agent allow/deny as code tools; exposure
|
|
26
|
+
# is the operator's (the /tools matrix), not automatic just because they
|
|
27
|
+
# are "data-defined" or MCP-discovered.
|
|
21
28
|
class OverlayToolRegistry
|
|
22
|
-
def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil
|
|
29
|
+
def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil,
|
|
30
|
+
mcp_registry: nil)
|
|
23
31
|
@base = base
|
|
24
32
|
@tool_store = tool_store
|
|
25
33
|
@http = http
|
|
26
34
|
@egress = egress
|
|
27
35
|
@egress_options = egress_options
|
|
28
36
|
@event_stream = event_stream
|
|
37
|
+
@mcp_registry = mcp_registry
|
|
29
38
|
end
|
|
30
39
|
|
|
31
|
-
# Base + dynamic, except
|
|
40
|
+
# Base + dynamic + mcp, except any that collide with something higher in
|
|
41
|
+
# the precedence (base > data-tools > mcp).
|
|
32
42
|
def entries
|
|
33
|
-
@base.entries + dynamic.reject { |e| code_tool?(e.name) }
|
|
43
|
+
@base.entries + dynamic.reject { |e| code_tool?(e.name) } + mcp_entries
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
def names
|
|
37
|
-
(@base.names + dynamic.map(&:name)).uniq
|
|
47
|
+
(@base.names + dynamic.map(&:name) + mcp_entries.map(&:name)).uniq
|
|
38
48
|
end
|
|
39
49
|
|
|
40
|
-
# -> instance (base wins) | raise NotFoundError.
|
|
50
|
+
# -> instance (base wins, then data-tools) | raise NotFoundError.
|
|
41
51
|
def resolve(name)
|
|
42
52
|
key = name.to_s
|
|
43
53
|
return @base.resolve(key) if code_tool?(key)
|
|
44
54
|
|
|
45
|
-
entry = dynamic.find { |e| e.name == key }
|
|
55
|
+
entry = dynamic.find { |e| e.name == key } || mcp_entries.find { |e| e.name == key }
|
|
46
56
|
raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}" unless entry
|
|
47
57
|
|
|
48
58
|
entry.factory.call
|
|
@@ -53,7 +63,7 @@ module Insika
|
|
|
53
63
|
key = name.to_s
|
|
54
64
|
return @base.side_effect?(key) if code_tool?(key)
|
|
55
65
|
|
|
56
|
-
entry = dynamic.find { |e| e.name == key }
|
|
66
|
+
entry = dynamic.find { |e| e.name == key } || mcp_entries.find { |e| e.name == key }
|
|
57
67
|
entry ? !!entry.metadata[:side_effect] : false
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -72,6 +82,16 @@ module Insika
|
|
|
72
82
|
@dynamic ||= build_dynamic
|
|
73
83
|
end
|
|
74
84
|
|
|
85
|
+
# No I/O (McpToolRegistry#entries only reads McpStore#tools_cache) ->
|
|
86
|
+
# recomputed fresh every call, unlike `dynamic` (nothing to reload).
|
|
87
|
+
# Drops anything already claimed by the base or a data-tool.
|
|
88
|
+
def mcp_entries
|
|
89
|
+
return [] unless @mcp_registry
|
|
90
|
+
|
|
91
|
+
claimed = @base.names + dynamic.map(&:name)
|
|
92
|
+
@mcp_registry.entries.reject { |e| claimed.include?(e.name) }
|
|
93
|
+
end
|
|
94
|
+
|
|
75
95
|
def build_dynamic
|
|
76
96
|
@tool_store.all_raw.filter_map { |raw| entry_for(raw) }
|
|
77
97
|
end
|
data/lib/insika/packaging.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Insika
|
|
|
72
72
|
tracked = `git ls-files -z 2>/dev/null`.split("\x0")
|
|
73
73
|
if tracked.empty?
|
|
74
74
|
tracked = Dir.glob("{lib,docs}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } +
|
|
75
|
-
%w[README.md LICENSE CHANGELOG.md bin/insika]
|
|
75
|
+
%w[README.md LICENSE CHANGELOG.md bin/insika bin/insika-router]
|
|
76
76
|
end
|
|
77
77
|
tracked.select { |file| payload_path?(file) }.reject { |file| excluded?(file) }
|
|
78
78
|
end
|
|
@@ -80,7 +80,7 @@ module Insika
|
|
|
80
80
|
|
|
81
81
|
# -> bool: is this repo-relative path part of the payload selection?
|
|
82
82
|
def payload_path?(file)
|
|
83
|
-
file.start_with?("lib/", "docs/") || %w[README.md LICENSE CHANGELOG.md bin/insika].include?(file)
|
|
83
|
+
file.start_with?("lib/", "docs/") || %w[README.md LICENSE CHANGELOG.md bin/insika bin/insika-router].include?(file)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# -> bool: is this file in the never-ship set?
|
|
@@ -119,6 +119,7 @@ module Insika
|
|
|
119
119
|
routes: h[:routes],
|
|
120
120
|
stuck_signal: h[:stuck_signal],
|
|
121
121
|
outputs: h[:outputs],
|
|
122
|
+
stt_prompt: h[:stt_prompt],
|
|
122
123
|
briefing_fields: h[:briefing_fields],
|
|
123
124
|
# grounding profile data — a plain Hash read with string keys
|
|
124
125
|
# by Insika::Grounding.parse per turn; nil round-trips as nil (= off).
|
|
@@ -148,7 +149,13 @@ module Insika
|
|
|
148
149
|
distill: h[:distill],
|
|
149
150
|
# harvest declaration — a plain Hash read with string keys
|
|
150
151
|
# by the miner/engine/doctor; nil round-trips as nil (= loop off).
|
|
151
|
-
harvest: h[:harvest]
|
|
152
|
+
harvest: h[:harvest],
|
|
153
|
+
# knowledge declaration — a plain Hash read with string keys
|
|
154
|
+
# by the extractor/engine/doctor; nil round-trips as nil (= loop off).
|
|
155
|
+
knowledge: h[:knowledge],
|
|
156
|
+
# schedules declaration — an Array of Hashes read with string
|
|
157
|
+
# keys by the ScheduleEngine/doctor/Studio; nil round-trips as nil.
|
|
158
|
+
schedules: h[:schedules]
|
|
152
159
|
)
|
|
153
160
|
end
|
|
154
161
|
|
|
@@ -30,6 +30,16 @@ module Insika
|
|
|
30
30
|
self
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Parity with SkillCatalog#add_roots: plugin prompt dirs join at the END
|
|
34
|
+
# (lowest precedence — a workspace prompt beats a plugin's same-named one).
|
|
35
|
+
def add_roots(dirs)
|
|
36
|
+
added = Array(dirs).map { |d| File.expand_path(d.to_s) } - @roots.map { |r| File.expand_path(r) }
|
|
37
|
+
return self if added.empty?
|
|
38
|
+
|
|
39
|
+
@roots.concat(added)
|
|
40
|
+
reload
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
private
|
|
34
44
|
|
|
35
45
|
def load_all
|