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.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. 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": … }` — and the Executor turns them
7
- # into a turn: audio is transcribed (text marked `source: :voice`), images
8
- # attach to the model ask and the first URL is `{{ctx.image_url}}` for
9
- # data/HTTP tools. This class owns the PURE parts (normalization) and
10
- # the STT SEAM (injectable — specs stub it; the default fetches the audio and
11
- # transcribes via RubyLLM behind a lazy require, so the core stays gem-free
12
- # at load). `Media::Output` is the generated-media half (WS9, saída): the
13
- # turn can PRODUCE an image or an audio clip when the agent opted in
14
- # (`AgentProfile#outputs`) AND the channel declared it can receive it
15
- # (`channel.capabilities`) — nothing leaks by default.
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 purpose — the
26
- # SURFACE validates the contract with `well_formed?` (a malformed part is a
27
- # 422 before dispatch); here a stray entry must not break the turn.
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 audio (with url). The edge raises a 422 on the first offender; the
46
- # engine itself stays lenient (`parts` skips strays so a non-HTTP transport
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
- def self.default_transcriber(stt_model:, stt_language: nil)
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
- def self.fetch_and_transcribe(url, model:, language:)
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
- audio = RubyLLM::Attachment.new(bytes)
102
- options = { model: model, assume_model_exists: true }
120
+ options = { model: model }
103
121
  options[:language] = language if language
104
- RubyLLM::Transcription.transcribe(audio, **options).text
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. usage is the provider's token
183
- # counts ({ input_tokens:, output_tokens: } — merged into the turn's
184
- # usage by the Executor); a provider without counts reports nothing.
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, assume_model_exists: true,
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 (PR #133). That was filtered by a REGEX on the
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
@@ -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) }
@@ -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.
@@ -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, no scheduler.
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) with the DATA-DEFINED tools from the ToolStore. Drop-in for ToolRegistry —
6
- # the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect?.,
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 cannot hijack
10
- # the name of a code tool (security, R3). The authoring Command also
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
- # PARITY: empty ToolStore ⇒ entries/resolve/side_effect? identical to the
16
- # pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
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: false) — they obey
19
- # the same per-agent allow/deny as code tools; exposure is the operator's
20
- # (the /tools matrix), not automatic just because they are "data-defined".
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 dynamic ones that collide with the base (base wins).
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
@@ -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