insika 0.3.0 → 0.8.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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  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 +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -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 +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -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 +99 -17
  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 +50 -19
  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/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. data/docs/understand.md +0 -10
@@ -15,13 +15,21 @@ module Insika
15
15
  # require the child agents to be resolvable in the SAME ProfileSource. A
16
16
  # Definition owns exactly one pack, so those patterns had no home in the DSL.
17
17
  class System
18
- attr_reader :definitions, :workflows, :runtime_options
18
+ attr_reader :definitions, :workflows, :mcp_instances, :runtime_options
19
19
 
20
20
  # `backend`: the store this system's graph owns. nil = the
21
21
  # historic path (INSIKA_DB, or memory when unset). Set by `Insika.embed`.
22
- def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
22
+ def initialize(definitions:, workflows: [], mcp_instances: [], runtime: {}, backend: nil)
23
23
  @definitions = definitions.freeze
24
24
  @workflows = workflows.freeze
25
+ # MCP instances are global to the graph (one McpStore, not per-agent),
26
+ # so a system-level `mcp` declaration and one nested inside a member
27
+ # `agent { … }` block land in the same set. A name declared both places
28
+ # is NOT an error here (unlike within one collection) — the later one
29
+ # (system-level, applied last) simply wins; Insika::DSL::Runtime upserts
30
+ # this combined list once at boot.
31
+ @mcp_instances = (definitions.flat_map(&:mcp_instances) + mcp_instances)
32
+ .each_with_object({}) { |m, acc| acc[m[:name]] = m }.values.freeze
25
33
  @backend = backend
26
34
  # Agent-level runtime knobs merged in declaration order, then the
27
35
  # system-level ones on top (an explicit `provider`/`api_key` in the
data/lib/insika/dsl.rb CHANGED
@@ -47,6 +47,7 @@ module Insika
47
47
  def initialize
48
48
  @definitions = []
49
49
  @workflows = []
50
+ @mcp_instances = []
50
51
  @runtime = {}
51
52
  end
52
53
 
@@ -54,7 +55,8 @@ module Insika
54
55
  instance_eval(&block) if block
55
56
  raise ArgumentError, "Insika.system needs at least one agent" if @definitions.empty?
56
57
 
57
- System.new(definitions: @definitions, workflows: @workflows, runtime: @runtime, backend: backend)
58
+ System.new(definitions: @definitions, workflows: @workflows,
59
+ mcp_instances: @mcp_instances, runtime: @runtime, backend: backend)
58
60
  end
59
61
 
60
62
  # Declares one agent — the SAME block the standalone `Insika.agent` takes.
@@ -101,6 +103,34 @@ module Insika
101
103
  def provider(name) = @runtime[:provider] = name.to_s
102
104
  def api_key(value) = @runtime[:api_key] = value.to_s
103
105
  def api_base(value) = @runtime[:api_base] = value.to_s
106
+
107
+ # Declares an MCP server instance: global to the graph, not
108
+ # any one agent — gated per agent through `tools_allow_groups` on the
109
+ # group `mcp:<name>`, same as any other tool group. `Insika::DSL::Runtime`
110
+ # upserts it into the McpStore at boot; the MOTOR-VS-FORJA rule applies —
111
+ # code is the TEMPLATE (transport/command/args/url/description always
112
+ # follow the DSL), but an operator's own `enabled`/`env`/`headers` edit
113
+ # via Studio/CLI/API, once the instance exists, is never clobbered back.
114
+ # mcp "tavily", transport: :http, url: "https://mcp.tavily.com/mcp",
115
+ # headers: { "Authorization" => "Bearer #{ENV["TAVILY_KEY"]}" }
116
+ # mcp "filesystem", transport: :stdio, command: "npx",
117
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
118
+ #
119
+ # A SYSTEM-level declaration (as opposed to inside one member `agent { }`
120
+ # block) has no single agent's config to auto-grant — it does NOT by
121
+ # itself give any agent access. Declare the `mcp` inside the specific
122
+ # `agent { }` block that needs it instead, where `Builder#mcp` auto-adds
123
+ # "mcp:<name>" to THAT agent's `tools_allow_groups` (below).
124
+ def mcp(name, transport: nil, command: nil, args: nil, url: nil,
125
+ headers: nil, env: nil, description: nil, enabled: true)
126
+ n = name.to_s
127
+ raise ArgumentError, "duplicate mcp instance in system: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
128
+
129
+ @mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
130
+ url: url, headers: headers, env: env, description: description,
131
+ enabled: enabled }
132
+ n
133
+ end
104
134
  end
105
135
 
106
136
  # Collects the declarations and emits a Insika::Pack. Declarations map 1:1 to
@@ -117,11 +147,12 @@ module Insika
117
147
  # correct once you restrict tools/skills. Visible in #to_pack — no hidden magic.
118
148
  @config[:policies] = %i[tool_allowlist skill_allowlist]
119
149
  @runtime = {} # non-pack knobs (llm provider/key/base) consumed by the runtime
150
+ @mcp_instances = []
120
151
  end
121
152
 
122
153
  def build(&block)
123
154
  instance_eval(&block) if block
124
- Definition.new(pack: to_pack, runtime: @runtime)
155
+ Definition.new(pack: to_pack, runtime: @runtime, mcp_instances: @mcp_instances)
125
156
  end
126
157
 
127
158
  # --- identity & model ------------------------------------------------
@@ -204,6 +235,34 @@ module Insika
204
235
  @config[:subagents] = ids.flatten.map(&:to_s)
205
236
  end
206
237
 
238
+ # --- mcp ---------------------------------------------------------------
239
+ # Declares an MCP server instance — see
240
+ # Insika::DSL::SystemBuilder#mcp for the transport/lifecycle doc; identical
241
+ # shape here for a standalone `Insika.agent { … }` script, or one member
242
+ # agent of a system, that wants one.
243
+ #
244
+ # Auto-adds "mcp:<name>" to THIS agent's `tools_allow_groups` — without
245
+ # it, a pack with no `data_tool` gets PackImporter's `tools_allow: []`
246
+ # (isolation default) and no `tools_allow_groups` at all, so
247
+ # Policy::ToolAllowlist#allowed_names resolves an EMPTY allowlist and the
248
+ # agent could never call the MCP tool it just declared (found writing the
249
+ # MCP templates — no existing spec exercised this path end to
250
+ # end). Same "auto-added to the allowlist" contract `data_tool` already
251
+ # gives its own tool name; `deny_tools` has no group-string equivalent
252
+ # yet, so a whole MCP group cannot be denied by name today.
253
+ def mcp(name, transport: nil, command: nil, args: nil, url: nil,
254
+ headers: nil, env: nil, description: nil, enabled: true)
255
+ n = name.to_s
256
+ raise ArgumentError, "duplicate mcp instance in agent: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
257
+
258
+ @mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
259
+ url: url, headers: headers, env: env, description: description,
260
+ enabled: enabled }
261
+ group = "mcp:#{n}"
262
+ (@config[:tools_allow_groups] ||= []) << group unless Array(@config[:tools_allow_groups]).include?(group)
263
+ n
264
+ end
265
+
207
266
  # --- knobs -----------------------------------------------------------
208
267
  def memory(on = true) = @config[:memory] = on
209
268
 
@@ -255,6 +314,41 @@ module Insika
255
314
  # miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
256
315
  def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
257
316
 
317
+ # The post-turn knowledge declaration: after a turn completes, the
318
+ # engine may extract durable CONCEPTS (facts, procedures, policies,
319
+ # objections) from it and persist them for later turns to retrieve.
320
+ # Pack data — merges, so repeated calls accumulate (like budget).
321
+ # `prompt`/`model` are pack-authored keys the DSL passes through.
322
+ # knowledge extract: true, retrieve: true, types: %w[fact policy]
323
+ def knowledge(hash) = (@config[:knowledge] ||= {}).merge!(hash.transform_keys(&:to_s))
324
+
325
+ # A recurring schedule: one declaration per call,
326
+ # named — a turn the ENGINE fires on its own tick, nobody has to
327
+ # remember. `cron` (5 fields) or `every` (plain interval), a tz for
328
+ # cron materialization, the synthetic inbound `message` that kicks each
329
+ # run, a session mode (`new` = a fresh session per run — the report
330
+ # case; `fixed` = one standing session), per-run `overrides`
331
+ # (turn_timeout / max_tool_calls / model) and `enabled`.
332
+ # schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
333
+ # message: "Run the daily report now.",
334
+ # overrides: { turn_timeout: 900, max_tool_calls: 200 }
335
+ # Distinct by shape from the `schedule_followup` TOOL (a one-shot,
336
+ # customer-facing, consent-gated contact); see docs/SCHEDULING.md.
337
+ def schedule(name, every: nil, cron: nil, tz: nil, message: nil,
338
+ session_mode: nil, session_id: nil, overrides: nil, enabled: nil)
339
+ id = name.to_s.downcase # the engine canonicalizes ids to lowercase
340
+ if Array(@config[:schedules]).any? { |s| s["id"] == id }
341
+ raise ArgumentError, "duplicate schedule in agent: #{id}"
342
+ end
343
+
344
+ entry = { "id" => id, "every" => every, "cron" => cron, "tz" => tz,
345
+ "message" => message, "session_mode" => session_mode,
346
+ "session_id" => session_id, "overrides" => overrides,
347
+ "enabled" => enabled }.compact
348
+ @config[:schedules] = Array(@config[:schedules]) + [entry]
349
+ id
350
+ end
351
+
258
352
  # Provider-interaction reliability, as DATA (WS3): retries + exponential
259
353
  # backoff on transient failures, a fallback model chain (mid-turn
260
354
  # rotation), and a circuit breaker per (tenant, provider/model) that
@@ -305,6 +399,13 @@ module Insika
305
399
  # tts: { model: "tts-1", voice: "alloy" }
306
400
  def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
307
401
 
402
+ # STT vocabulary hint (WS9): domain words (product names, brand terms)
403
+ # the transcriber should expect on THIS agent's voice notes — passed
404
+ # straight through to the Whisper-family provider's `prompt:`. Falls
405
+ # back to INSIKA_STT_PROMPT (deployment default) when unset.
406
+ # stt_prompt "Ocean Drop, tênis, boné trucker, chinelo"
407
+ def stt_prompt(text) = @config[:stt_prompt] = text.to_s
408
+
308
409
  # The engine's "Tool discipline" block in the system prompt (retry a
309
410
  # weak/empty tool result with a different approach before giving up).
310
411
  # ON by default — this setter exists to turn it OFF:
@@ -72,10 +72,10 @@ module Insika
72
72
 
73
73
  # Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not
74
74
  # a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the
75
- # deprecation window). Deliberately NOT OPENCLAW_ (shared with the OpenClaw gateway
76
- # product, which sets its own OPENCLAW_HOME/_STATE_DIR/… — the engine merely borrows
77
- # 3 names for interop), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the
78
- # OpenTelemetry SDK owns its env).
75
+ # deprecation window). Deliberately NOT OPENCLAW_ (the OpenClaw gateway product
76
+ # sets its own OPENCLAW_HOME/_STATE_DIR/… on the same host; the engine reads none
77
+ # of them), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the OpenTelemetry
78
+ # SDK owns its env).
79
79
  OWNED_PREFIXES = [PREFIX, LEGACY_PREFIX].freeze
80
80
 
81
81
  BOOLEANS = %w[1 0 true false yes no on off].freeze
@@ -110,6 +110,9 @@ module Insika
110
110
  spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
111
111
  spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
112
112
  spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
113
+ spec(name: "INSIKA_ARTIFACT_SIGNING_KEY", secret: true, description: "HMAC key for signed artifact links (INSIKA_ARTIFACT_SIGNING_TTL). Unset -> no signed artifact surface."),
114
+ spec(name: "INSIKA_ARTIFACT_SIGNING_TTL", type: :integer, description: "Seconds a signed artifact link stays valid (default 604800 = 7 days)."),
115
+ spec(name: "INSIKA_ARTIFACT_MAX_BYTES", type: :integer, description: "Size cap on an artifact's content in bytes (default 1048576)."),
113
116
  spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning."),
114
117
  spec(name: "INSIKA_BOOT_ID", description: "Boot generation id shared by all workers of one container start; the recovery task sweep runs once per id. Unset -> every boot sweeps."),
115
118
  spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
@@ -117,6 +120,7 @@ module Insika
117
120
  spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
118
121
  spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
119
122
  spec(name: "INSIKA_STT_LANGUAGE", description: "Language hint for the transcription of audio message parts (WS9)."),
123
+ spec(name: "INSIKA_STT_PROMPT", description: "Deployment-wide vocabulary hint (product names, brand terms) for audio transcription (WS9). Overridden per agent by the profile's stt_prompt."),
120
124
  spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
121
125
  spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
122
126
  spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
@@ -129,9 +133,19 @@ module Insika
129
133
  spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
130
134
  spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
131
135
  spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
132
- spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
133
- spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
134
- spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
136
+ spec(name: "INSIKA_MCP_STDIO", type: :boolean, description: "Allow stdio MCP instances to spawn a child process (arbitrary command execution by config). Unset -> stdio instances save but refuse to start."),
137
+ spec(name: "INSIKA_ROUTER_BACKENDS", type: :csv, description: "comma-separated backend URLs for `insika-router` (static discovery, the Railway shape). Exactly one of this or INSIKA_ROUTER_BACKENDS_DNS."),
138
+ spec(name: "INSIKA_ROUTER_BACKENDS_DNS", description: "a headless-Service hostname `insika-router` re-resolves on an interval (the Kubernetes shape). Requires INSIKA_ROUTER_BACKEND_PORT."),
139
+ spec(name: "INSIKA_ROUTER_BACKEND_PORT", type: :integer, description: "the engine port on every DNS-resolved backend pod (required with INSIKA_ROUTER_BACKENDS_DNS)."),
140
+ spec(name: "INSIKA_ROUTER_DNS_INTERVAL", type: :integer, description: "seconds between `insika-router` DNS re-resolves (default 15)."),
141
+ spec(name: "INSIKA_ROUTER_BODY_MAX_BYTES", type: :integer, description: "size cap `insika-router` will parse looking for a session key before falling back to round-robin (default 262144; never bounds what is forwarded)."),
142
+ spec(name: "INSIKA_ROUTER_BACKEND_TIMEOUT", type: :integer, description: "`insika-router`'s connect/read timeout to a backend, in seconds (default 10)."),
143
+ spec(name: "INSIKA_ROUTER_HOST", description: "bind address for `insika-router` itself (default 0.0.0.0)."),
144
+ spec(name: "INSIKA_ROUTER_PORT", type: :integer, description: "listen port for `insika-router` itself (default 9090)."),
145
+ spec(name: "INSIKA_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN when unset)."),
146
+ spec(name: "INSIKA_PLUGIN_DIR", type: :path, description: "Workspace plugin root (directories with insika.plugin.yml). Loaded at boot; ids still need INSIKA_PLUGINS."),
147
+ spec(name: "INSIKA_PLUGINS", type: :csv, description: "Plugin ids to enable from the workspace/bundled roots. Announced gems are enabled by installing them."),
148
+ spec(name: "INSIKA_PLUGINS_DISABLED", type: :csv, description: "Plugin ids that never load — the absolute veto, wins over INSIKA_PLUGINS and over an announced gem."),
135
149
  spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
136
150
  spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
137
151
  ].freeze
@@ -11,10 +11,26 @@ module Insika
11
11
  module Evals
12
12
  # A curated behavior case, loaded from a data file (evals/golden/<agent>/*.yml).
13
13
  # Data, not code — same spirit as tools-as-data. See evals/README.md for the format.
14
- Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, keyword_init: true) do
15
- # The user messages to replay, in order.
14
+ #
15
+ # A case is ONE of two shapes: `turns:` (a scripted replay) or
16
+ # `persona:` (a conversation the Simulator GENERATES). A persona
17
+ # case is `simulated?` — the replay Runner skips it, and the Simulator drives it.
18
+ #
19
+ # `tenant` (C3.1): which tenant authored this case — "platform" (the
20
+ # single-tenant default, like `save_artifact`'s own binding_tenant) unless the
21
+ # case declares one. `run_persona_eval` uses it to keep a QA agent from ever
22
+ # running (or even seeing) another tenant's persona case in the same store.
23
+ Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
24
+ keyword_init: true) do
25
+ # The user messages to replay, in order. Empty for a persona case: a generated
26
+ # conversation has no scripted turns.
16
27
  def user_turns = turns.map { |t| t["user"] }
17
28
 
29
+ # The simulated customer. nil for a scripted case.
30
+ def simulated? = !persona.nil?
31
+
32
+ def opens_with = persona ? persona.opens_with : user_turns.first
33
+
18
34
  # Tool refs the case expects; a trailing "?" marks OPTIONAL (never fails).
19
35
  # -> [{ name:, optional: }]
20
36
  def tools_called
@@ -85,7 +101,12 @@ module Insika
85
101
 
86
102
  id = presence(raw["id"]) || (raise InvalidGolden, "#{source}: 'id' is required")
87
103
  agent = presence(raw["agent"]) || (raise InvalidGolden, "#{source}: 'agent' is required (case '#{id}')")
88
- turns = normalize_turns(raw["turns"], id: id, source: source)
104
+ persona = normalize_persona(raw["persona"], id: id, source: source)
105
+ if persona && !raw["turns"].nil?
106
+ raise InvalidGolden, "#{source}: a case is ONE shape — 'turns' or 'persona', not both (case '#{id}')"
107
+ end
108
+
109
+ turns = persona ? [] : normalize_turns(raw["turns"], id: id, source: source)
89
110
  expect = raw["expect"] || {}
90
111
  raise InvalidGolden, "#{source}: 'expect' must be a mapping (case '#{id}')" unless expect.is_a?(Hash)
91
112
 
@@ -96,9 +117,25 @@ module Insika
96
117
  end
97
118
 
98
119
  reference = normalize_reference(raw["reference"], id: id, source: source)
120
+ tenant = presence(raw["tenant"]) || "platform"
99
121
 
100
122
  Golden.new(id: id, agent: agent, turns: turns, expect: expect,
101
- requires: requires, reference: reference, source: source)
123
+ requires: requires, reference: reference, source: source, persona: persona,
124
+ tenant: tenant)
125
+ end
126
+
127
+ # `persona:` is the alternative shape to `turns:`: the
128
+ # conversation is GENERATED, not replayed. Malformed is REFUSED — a persona
129
+ # without `knows` or `max_turns` would simulate nothing. The PersonaLoader
130
+ # already prefixes its messages with the source path; the case id is added
131
+ # ONCE here (the loader is shared by the persona-file CLI, which has no case
132
+ # shape).
133
+ def normalize_persona(raw, id:, source:)
134
+ return nil if raw.nil?
135
+
136
+ PersonaLoader.build(raw, source: source)
137
+ rescue PersonaLoader::InvalidPersona => e
138
+ raise InvalidGolden, "#{e.message} (case '#{id}')"
102
139
  end
103
140
 
104
141
  # reference: { "source" => String?, "messages" => [{ "role" =>, "text" =>,
@@ -67,7 +67,28 @@ module Insika
67
67
  return nil if rubric.empty?
68
68
 
69
69
  prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy)
70
- min = golden.min_score || DEFAULT_MIN_SCORE
70
+ score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
71
+ end
72
+
73
+ # A whole CONVERSATION (a simulated run) scored against the
74
+ # rubric — the transcript, not its last turn. `transcript` is
75
+ # [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
76
+ # a rubric. Same panel, same rules as `score`: the rubric that scores a
77
+ # scripted replay scores a generated one.
78
+ def score_conversation(rubric:, transcript:, policy: nil, min_score: DEFAULT_MIN_SCORE)
79
+ rubric = rubric.to_s.strip
80
+ return nil if rubric.empty?
81
+
82
+ prompt = conversation_prompt(rubric, transcript, policy)
83
+ score_prompt(prompt, min_score)
84
+ end
85
+
86
+ private
87
+
88
+ # The panel machinery shared by `score` (a single reply) and
89
+ # `score_conversation` (a generated transcript): ask each judge, combine,
90
+ # decide by min_agreement. -> Verdict.
91
+ def score_prompt(prompt, min)
71
92
  panel = @asks.map { |ask| judge_once(ask, prompt, min) }
72
93
 
73
94
  agreed = panel.count { |j| j[:pass] }
@@ -77,7 +98,31 @@ module Insika
77
98
  judges: panel.map { |j| j[:score] })
78
99
  end
79
100
 
80
- private
101
+ # The judge reads the FULL conversation as the customer lived it — every
102
+ # user turn and every assistant reply interleaved. A rubric about the whole
103
+ # exchange ("does it discover the objective before recommending?") is
104
+ # unanswerable from the last reply alone.
105
+ def conversation_prompt(rubric, transcript, policy)
106
+ lines = Array(transcript).map do |m|
107
+ role = m[:role].to_s == "user" ? "customer" : "assistant"
108
+ "#{role}: #{m[:text].to_s.strip}"
109
+ end.join("\n")
110
+ <<~PROMPT
111
+ You are a strict QA judge for a customer-service AI assistant. Judge the
112
+ ASSISTANT'S HANDLING OF THE WHOLE CONVERSATION against the RUBRIC — nothing
113
+ else.
114
+
115
+ RUBRIC:
116
+ #{rubric}
117
+ #{policy_clause(policy)}
118
+ CONVERSATION (in order):
119
+ #{lines}
120
+
121
+ Score from 0.0 (fails the rubric) to 1.0 (fully meets it). Respond with ONLY a
122
+ JSON object, no prose:
123
+ {"score": <0..1>, "reason": "<one short sentence>"}
124
+ PROMPT
125
+ end
81
126
 
82
127
  # One model's verdict: its own samples, its own median, its own pass/fail.
83
128
  def judge_once(ask, prompt, min)
@@ -84,6 +84,17 @@ module Insika
84
84
  end.flatten.join("\n")
85
85
  end
86
86
 
87
+ # A GENERATED (simulated) conversation as the judge reads it: the transcript
88
+ # is already interleaved [{ role: "user"|"assistant", text: }] — map it to the
89
+ # same customer/assistant lines a replay produces, so pairwise compares like
90
+ # for like (a simulated run can be compared against the incumbent).
91
+ def self.transcript_text(messages)
92
+ Array(messages).map do |m|
93
+ role = m[:role].to_s == "user" ? "customer" : "assistant"
94
+ "#{role}: #{m[:text].to_s.strip}"
95
+ end.join("\n")
96
+ end
97
+
87
98
  # The incumbent's half. Human turns are NOT flagged to the judge: what it grades
88
99
  # is the conversation as the customer received it, and telling it "a person wrote
89
100
  # this one" is an invitation to grade the author instead. The fact is carried to
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Evals
5
+ # A SIMULATED CUSTOMER — the data that turns a scripted case into a
6
+ # generated conversation. Pure data: goal, style, the opening
7
+ # message, the ONLY facts the persona may assert, and a hard turn cap. The
8
+ # persona is played by a model (the cheap utility_model) with this as its whole
9
+ # instruction; the anti-invention rule below is the soul of the feature — a
10
+ # simulator that invents an order number produces a conversation the agent
11
+ # could never have had, and a case that tests nothing.
12
+ Persona = Struct.new(:goal, :style, :opens_with, :knows, :max_turns, keyword_init: true) do
13
+ def to_h
14
+ { "goal" => goal, "style" => style, "opens_with" => opens_with,
15
+ "knows" => knows, "max_turns" => max_turns }.compact
16
+ end
17
+
18
+ # The persona as a whole instruction. The `knows` facts are the ONLY
19
+ # assertions the persona may make; anything else is answered with
20
+ # ignorance — "não sei", "não tenho isso aqui" — exactly like a real
21
+ # customer who does not have the fact. `transcript` is the conversation
22
+ # so far, in order, as [{ role: "user"|"assistant", text: }].
23
+ def prompt(transcript)
24
+ facts = knows.map { |k, v| "- #{k}: #{v}" }.join("\n")
25
+ <<~PROMPT
26
+ You are simulating a customer in a chat with a store's virtual assistant.
27
+ Stay in character. You are not helping the assistant; you are the person
28
+ it serves.
29
+
30
+ GOAL: #{goal}
31
+ STYLE: #{style || "short, natural messages; answers what is asked"}
32
+
33
+ FACTS YOU KNOW — the ONLY facts you may assert:
34
+ #{facts}
35
+
36
+ RULES:
37
+ 1. You may ONLY assert the facts above. Asked about anything else, you do
38
+ not know it — answer with ignorance, like a real customer without that
39
+ fact (you have no order number, no name, no date, no price beyond the
40
+ facts above). Never invent an order number, a name, a date, a price or
41
+ any other detail that is not in FACTS YOU KNOW.
42
+ 2. Reply with ONLY the customer's next message.
43
+ 3. When your goal has been met, end the message with the marker
44
+ <<goal_met>>. When you give up (the assistant cannot get you there),
45
+ end the message with the marker <<gave_up>>. Otherwise end with no
46
+ marker.
47
+
48
+ CONVERSATION SO FAR:
49
+ #{transcript.map { |m| "#{m[:role]}: #{m[:text]}" }.join("\n")}
50
+
51
+ Your next message:
52
+ PROMPT
53
+ end
54
+ end
55
+
56
+ # Loads + validates a persona mapping (the `persona:` key of a golden, or the
57
+ # `--persona` file of the simulate CLI). Fails LOUD on a malformed persona —
58
+ # a silently relaxed max_turns or a missing knows would produce a simulation
59
+ # that tests nothing.
60
+ module PersonaLoader
61
+ class InvalidPersona < StandardError; end
62
+
63
+ module_function
64
+
65
+ def build(raw, source: "(inline)")
66
+ raise InvalidPersona, "#{source}: persona must be a mapping" unless raw.is_a?(Hash)
67
+
68
+ goal = presence(raw["goal"])
69
+ raise InvalidPersona, "#{source}: persona needs a non-empty 'goal'" if goal.nil?
70
+
71
+ knows = raw["knows"]
72
+ unless knows.is_a?(Hash) && !knows.empty?
73
+ raise InvalidPersona, "#{source}: persona needs a non-empty 'knows' mapping (the only facts it may assert)"
74
+ end
75
+
76
+ opens = presence(raw["opens_with"])
77
+ raise InvalidPersona, "#{source}: persona needs a non-empty 'opens_with'" if opens.nil?
78
+
79
+ max = raw["max_turns"]
80
+ unless max.is_a?(Integer) && max.positive?
81
+ raise InvalidPersona, "#{source}: persona needs 'max_turns' as a positive integer"
82
+ end
83
+
84
+ Persona.new(
85
+ goal: goal, style: presence(raw["style"]),
86
+ opens_with: opens,
87
+ knows: knows.transform_keys(&:to_s).transform_values(&:to_s),
88
+ max_turns: max
89
+ )
90
+ end
91
+
92
+ def presence(v)
93
+ s = v.to_s.strip
94
+ s.empty? ? nil : s
95
+ end
96
+ end
97
+ end
98
+ end
@@ -50,6 +50,15 @@ module Insika
50
50
  end
51
51
 
52
52
  def run_case(golden)
53
+ # A persona case is GENERATED, not replayed: the turns do not exist until a
54
+ # Simulator drives the conversation. The replay Runner cannot run it, and a
55
+ # silent no-op would read as a pass — so it is SKIPPED with the reason, and
56
+ # the Simulator (the simulate CLI) is the only driver.
57
+ if golden.simulated?
58
+ return RunCase.new(result: Assertions.skip(golden, "simulated case (persona) — drive it with `insika evals:simulate`"),
59
+ timings: [])
60
+ end
61
+
53
62
  skip = skip_reason(golden)
54
63
  return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
55
64