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
data/bin/insika CHANGED
@@ -16,6 +16,7 @@
16
16
  # DEEPSEEK, no seeding. So it is safe to run against a live deployment's volume.
17
17
 
18
18
  require "optparse"
19
+ require "fileutils"
19
20
  require_relative "../lib/insika"
20
21
 
21
22
  module Insika
@@ -31,10 +32,17 @@ module Insika
31
32
  when "harvest" then harvest(argv)
32
33
  when "harvest:negative" then harvest_negative_import(argv)
33
34
  when "harvest:criterion" then harvest_criterion_check(argv)
35
+ when "tools:report" then tools_report(argv)
36
+ when "knowledge:backfill" then knowledge_backfill(argv)
37
+ when "knowledge:export" then knowledge_export(argv)
34
38
  when "soak" then exit(Insika::Soak::Runner.main(argv, stdout: $stdout, stderr: $stderr))
35
39
  when "evals:import" then evals_import(argv)
36
40
  when "evals:export" then evals_export(argv)
37
41
  when "evals:baseline" then evals_baseline(argv)
42
+ when "evals:simulate" then evals_simulate(argv)
43
+ when "demo:seed" then demo_seed(argv)
44
+ when "mcp" then mcp(argv)
45
+ when "new" then new_from_template(argv)
38
46
  when "version", "--version", "-v" then puts "insika #{Insika::VERSION}"
39
47
  when "help", "--help", "-h" then puts usage
40
48
  else
@@ -58,16 +66,52 @@ module Insika
58
66
  seed the profile's negative list from a rules file
59
67
  harvest:criterion check --file F
60
68
  strict-load the frozen conversion criterion
69
+ tools:report [--agent ID] [--days N] [--json]
70
+ tool usage per agent over the stored traces:
71
+ allowlisted-but-never-called, error rate > 30%
72
+ in the window, and stale (not called in N days).
73
+ Read-only — the operator removes
74
+ knowledge:backfill --agent ID [--since ISO8601] [--json]
75
+ the recovery path for post-turn extraction: replays
76
+ the agent's stored sessions through the same
77
+ extractor a live turn uses
78
+ knowledge:export --agent ID [--out DIR] [--tenant T] [--format md|graphml]
79
+ writes one <name>.md per learned concept under DIR —
80
+ a dump, not a converter (the storage format IS the
81
+ export format), for okf-gem/graphify tooling; --format
82
+ graphml writes one knowledge.graphml graph file instead
61
83
  soak --run | --verify FILE | --preflight the 72h soak
62
84
  evals:import [--dir D] load the golden corpus into the store (editable in Studio)
63
85
  evals:export [--dir D] write the stored cases back out as YAML
64
86
  evals:baseline <sub> the accepted state per agent: show | import | export
87
+ evals:simulate --persona F --target <agent|url>
88
+ generate a simulated conversation against an
89
+ agent: a persona model talks to the
90
+ target until max_turns or a stop marker; the
91
+ transcript is scored by the judge panel.
92
+ Requires --staging or --eval-profile (a simulated
93
+ run must not write for real); --eval-profile is
94
+ verified against the target's derived
95
+ side-effect tools (--eval-tools).
96
+ demo:seed [--force] populate the "demo-store" agent with enough data to see
97
+ every Studio loop at once (funnel+baseline, follow-ups,
98
+ refinement runs, approvals, distillation, evals+baseline)
99
+ mcp list the configured MCP instances
100
+ mcp add --name N [...] add/edit an instance (see mcp add --help)
101
+ mcp remove NAME delete an instance
102
+ mcp import FILE.json upsert every entry of a `mcpServers` JSON document
103
+ mcp test NAME connect live, list its tools, print them (or the error)
104
+ mcp refresh NAME same as test, plus caches the result for display
105
+ new --list the template gallery: trail + one-line description
106
+ new <template> [dir] copy a template (default dir: ./<template>) and print the run line
65
107
  version print the engine version
66
108
  help show this help
67
109
 
68
110
  Environment:
69
111
  INSIKA_DB SQLite path (durable). Unset -> ephemeral memory.
70
112
  INSIKA_CONFIG_STRICT when truthy, the server refuses boot on any finding.
113
+ INSIKA_MCP_STDIO set to 1 to allow stdio MCP instances to start
114
+ (arbitrary command execution by config — off by default)
71
115
  TXT
72
116
  end
73
117
 
@@ -273,8 +317,552 @@ def baseline_export(path)
273
317
  puts "exported #{merged.size} case(s) from #{store.agents.size} agent baseline(s) to #{path}"
274
318
  end
275
319
 
320
+ # insika evals:simulate --persona F --target <agent|url> [--staging|--eval-profile]
321
+ #
322
+ # Generates a SIMULATED conversation: a persona model (the cheap
323
+ # utility_model) plays a customer with the persona as its whole instruction — and
324
+ # one hard rule: it may ONLY assert what the persona's `knows` contains. The
325
+ # target agent answers over the same Transport the replay uses (HttpTransport
326
+ # against a running deployment, or a thin A2A transport when --target is a URL).
327
+ #
328
+ # SAFETY (fixed in the spec): a simulated conversation must not write for real,
329
+ # because `simulated: true` marks the transcript, it does not disarm the tools.
330
+ # So this CLI REQUIRES one of:
331
+ # --staging the operator declares the target is a staging deployment
332
+ # --eval-profile the target runs an eval profile (side-effect tools swapped
333
+ # for dry-runs) — a VERIFIED declaration, not a trust-me flag:
334
+ # the CLI derives the target's side-effect tools from the
335
+ # deployment's own registry (GET /v1/agents/:id) or the local
336
+ # store (INSIKA_DB), and requires --eval-tools to cover every
337
+ # one of them. An A2A target (no reachable registry) requires
338
+ # the explicit --eval-tools list.
339
+ # and refuses to run without one. Every run is marked `simulated: true`.
340
+ def evals_simulate(argv)
341
+ require "yaml"
342
+ require "json"
343
+ require "fileutils"
344
+ require "time"
345
+ opts = { base_url: ENV["INSIKA_URL"] || "http://localhost:9292",
346
+ token: ENV["INSIKA_GATEWAY_TOKEN"] || ENV["ADMIN_TOKEN"] || "local-demo",
347
+ timeout: 120, persona_model: ENV["EVAL_PERSONA_MODEL"],
348
+ judge_model: ENV["EVAL_JUDGE_MODEL"], judge_provider: ENV["EVAL_JUDGE_PROVIDER"] }
349
+ OptionParser.new do |o|
350
+ o.banner = "Usage: insika evals:simulate --persona F --target <agent|url> [options]"
351
+ o.on("--persona FILE", "the persona YAML — a case with a `persona:` key (required)") { |v| opts[:persona] = v }
352
+ o.on("--target T", "agent id (over --base-url) or an A2A http(s) URL (required)") { |v| opts[:target] = v }
353
+ o.on("--base-url URL", "deployment base URL (default #{opts[:base_url]})") { |v| opts[:base_url] = v }
354
+ o.on("--token T", "gateway token (default INSIKA_GATEWAY_TOKEN/local-demo)") { |v| opts[:token] = v }
355
+ o.on("--timeout N", Integer, "per-turn read timeout, and the A2A poll budget in seconds (default 120)") { |v| opts[:timeout] = v }
356
+ o.on("--staging", "the target is a staging deployment (side effects acceptable)") { opts[:staging] = true }
357
+ o.on("--eval-profile", "the target runs an eval profile (side-effect tools swapped for fakes)") { opts[:eval_profile] = true }
358
+ o.on("--eval-tools LIST", "comma-separated side-effect tools the eval profile swaps (verified against the derived set; required for an A2A target)") { |v| opts[:eval_tools] = v }
359
+ o.on("--persona-model MODEL", "the model playing the customer (default settings utility_model)") { |v| opts[:persona_model] = v }
360
+ o.on("--judge-model MODEL", "score the transcript's rubric with this model") { |v| opts[:judge_model] = v }
361
+ o.on("--judge-provider PROVIDER", "provider for the judge model") { |v| opts[:judge_provider] = v }
362
+ o.on("--pairwise", "compare the simulated transcript against the case's `reference:`") { opts[:pairwise] = true }
363
+ o.on("--conv ID", "conversation id (default: sim-<case id>-<random>, unique per run)") { |v| opts[:conv] = v }
364
+ o.on("--out FILE", "write the JSON report here") { |v| opts[:out] = v }
365
+ end.parse!(argv)
366
+
367
+ abort "insika evals:simulate: --persona is required" if opts[:persona].to_s.empty?
368
+ abort "insika evals:simulate: --target is required (<agent> or an A2A <url>)" if opts[:target].to_s.empty?
369
+ unless opts[:staging] || opts[:eval_profile]
370
+ abort "insika evals:simulate: a simulated run must not write for real — pass --staging " \
371
+ "(the target is a staging deployment) or --eval-profile (side-effect tools are swapped)"
372
+ end
373
+
374
+ golden = load_sim_persona(opts[:persona])
375
+ # --target names the agent actually simulated. An agent id overrides the
376
+ # persona file's `agent:` (the file is a reusable script); a URL means an A2A
377
+ # endpoint, where the agent id is irrelevant (the transport ignores it).
378
+ a2a_target = opts[:target].to_s.match?(%r{\Ahttps?://})
379
+ target_agent = a2a_target ? golden.agent : opts[:target].to_s
380
+ safety = build_sim_safety(opts, golden, target_agent, a2a_target)
381
+
382
+ settings = eval_settings_from_env
383
+ ask = build_persona_ask(opts, settings)
384
+ judge = build_sim_judge(opts, settings)
385
+
386
+ transport = build_sim_transport(opts)
387
+ simulator = Insika::Evals::Simulator.new(transport: transport, ask: ask, safety: safety)
388
+ # Unique per run: two runs sharing a conv share the deployment SESSION, so the
389
+ # second inherits the first's history — including across the A/B arms of a
390
+ # paired comparison, which it silently invalidates.
391
+ require "securerandom"
392
+ conv = opts[:conv] || "sim-#{golden.id}-#{SecureRandom.hex(4)}"
393
+ run = simulator.run(persona: golden.persona, agent: target_agent, conv: conv)
394
+
395
+ verdict = judge && judge.score_conversation(
396
+ rubric: golden.rubric, transcript: run.transcript, policy: golden.policy,
397
+ min_score: golden.min_score || Insika::Evals::Judge::DEFAULT_MIN_SCORE
398
+ )
399
+
400
+ pairwise = if opts[:pairwise] && golden.reference?
401
+ p = build_sim_pairwise(opts, settings)
402
+ if p
403
+ p.compare_texts(
404
+ ours: Insika::Evals::Pairwise.transcript_text(run.transcript),
405
+ theirs: Insika::Evals::Pairwise.reference_transcript(golden.reference_messages),
406
+ vs: golden.human_assisted? ? "human-assisted" : "agent"
407
+ )
408
+ end
409
+ end
410
+
411
+ at = Time.now.utc.iso8601
412
+ report = { "simulated" => true, "at" => at, "id" => golden.id, "agent" => golden.agent,
413
+ "conv" => conv, "stop" => run.stop.to_s, "turns" => run.turns, "error" => run.error,
414
+ "persona" => golden.persona.to_h, "transcript" => run.transcript }
415
+ if verdict
416
+ report["judge"] = { "score" => verdict.score, "pass" => verdict.pass, "reason" => verdict.reason }
417
+ # A split panel must be visible in the report (the whole point of a panel
418
+ # over a lone judge): one entry per judge model, in settings order.
419
+ report["judge"]["judges"] = verdict.judges if verdict.judges
420
+ end
421
+ report["pairwise"] = { "outcome" => pairwise.outcome, "vs" => pairwise.vs, "reason" => pairwise.reason } if pairwise
422
+ puts simulate_markdown(report, verdict, pairwise)
423
+ if opts[:out]
424
+ FileUtils.mkdir_p(File.dirname(File.expand_path(opts[:out])))
425
+ File.write(opts[:out], JSON.pretty_generate(report))
426
+ puts "report: #{opts[:out]}"
427
+ end
428
+ exit(run.stop == :error || (verdict && !verdict.pass) ? 1 : 0)
429
+ rescue Insika::Evals::PersonaLoader::InvalidPersona, Insika::Evals::GoldenLoader::InvalidGolden,
430
+ Insika::Evals::Simulator::UnsafeTarget => e
431
+ warn "insika evals:simulate: #{e.message}"
432
+ exit 2
433
+ end
434
+
435
+ # --persona: a case YAML carrying a `persona:` key (the alternative
436
+ # shape). A case without turns but with a persona is the only thing this CLI
437
+ # drives — a scripted case has nothing to simulate.
438
+ def load_sim_persona(path)
439
+ raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
440
+ golden = Insika::Evals::GoldenLoader.build(raw, source: path)
441
+ raise Insika::Evals::GoldenLoader::InvalidGolden, "#{path}: needs a `persona:` (a simulated case)" unless golden.simulated?
442
+
443
+ golden
444
+ rescue Errno::ENOENT => e
445
+ raise Insika::Evals::GoldenLoader::InvalidGolden, e.message
446
+ end
447
+
448
+ # The safety gate for a run. --staging is a straight declaration. --eval-profile
449
+ # is a VERIFIED one: the CLI derives the target's side-effect tools (see
450
+ # derive_side_effect_tools) and refuses unless the operator's --eval-tools names
451
+ # every one of them — an eval profile that leaves a side-effect tool unswapped is
452
+ # a lie, and a bare `--eval-profile` on a write-capable agent is exactly the
453
+ # trust-me flag the docs promise is not what happens.
454
+ def build_sim_safety(opts, golden, target_agent, a2a_target)
455
+ return Insika::Evals::Simulator::Safety.staging if opts[:staging]
456
+
457
+ declared = opts[:eval_tools].to_s.split(",").map(&:strip).reject(&:empty?)
458
+ derived = if a2a_target
459
+ nil # a remote A2A agent has no reachable registry — the list must be declared
460
+ else
461
+ derive_side_effect_tools(opts, target_agent)
462
+ end
463
+
464
+ if derived
465
+ uncovered = derived - declared
466
+ unless uncovered.empty?
467
+ abort "insika evals:simulate: --eval-profile, but the target's derived side-effect " \
468
+ "tool(s) (#{uncovered.join(', ')}) are not declared in --eval-tools — an eval " \
469
+ "profile must swap EVERY side-effect tool (or use --staging)"
470
+ end
471
+ elsif declared.empty?
472
+ abort "insika evals:simulate: cannot derive the target's side-effect tools (no " \
473
+ "/v1/agents/:id answer and no local store) — declare the swap list explicitly " \
474
+ "with --eval-tools, or use --staging"
475
+ end
476
+
477
+ Insika::Evals::Simulator::Safety.new(eval_profile: true, side_effect_tools: derived || [],
478
+ swapped_tools: declared)
479
+ end
480
+
481
+ # The target's side-effect tools — DERIVED, never hand-typed into the safety
482
+ # gate. -> [names] | nil when derivation is impossible. Local store first (the
483
+ # definitive answer when INSIKA_DB points at the deployment); otherwise the
484
+ # deployment's own registry over GET /v1/agents/:id (`side_effect_tools` — the
485
+ # server computes it from the same allowlist − deny resolution the capability
486
+ # view uses).
487
+ def derive_side_effect_tools(opts, agent)
488
+ local = local_side_effect_tools(agent)
489
+ return local unless local.nil?
490
+
491
+ capabilities = Insika::Evals::HttpCapabilities.new(base_url: opts[:base_url], token: opts[:token])
492
+ answer = capabilities.for(agent)
493
+ answer && !answer["side_effect_tools"].nil? ? Array(answer["side_effect_tools"]).map(&:to_s) : nil
494
+ end
495
+
496
+ # Derivation from the local store (INSIKA_DB set): the profile's reachable set
497
+ # intersected with the store-backed overlay registry's side-effect marks. nil
498
+ # when there is no store or the agent is not in it.
499
+ def local_side_effect_tools(agent)
500
+ db = Insika::EnvSchema.read("INSIKA_DB", ENV)
501
+ return nil if db.nil? || db.empty?
502
+
503
+ backend = Insika::Wiring::Graph.backend_from_env
504
+ config_store = Insika::ConfigStore.new(store: backend)
505
+ profile = Insika::StoredProfileSource.new(config_store: config_store).fetch(agent)
506
+ return nil if profile.nil?
507
+
508
+ registry = Insika::OverlayToolRegistry.new(
509
+ base: Insika::ToolRegistry.new, tool_store: Insika::ToolStore.new(config_store: config_store),
510
+ http: Insika::HttpClient.new
511
+ )
512
+ Insika::Evals::EvalProfile.side_effect_tools(profile, registry)
513
+ end
514
+
515
+ # One place for the CLI's provider keys. Every synchronous CLI command that
516
+ # builds its own extractor/miner/judge ask (no full app boot, so LlmConfigurator
517
+ # never ran) calls this first — a judge panel is only as multi-model as the keys
518
+ # this block wires: a settings panel pointing at OpenRouter models silently
519
+ # 401'd here while the engine (LlmConfigurator) knew the key all along.
520
+ def configure_cli_llm!
521
+ require "ruby_llm"
522
+ RubyLLM.configure do |c|
523
+ c.deepseek_api_key = ENV["DEEPSEEK_API_KEY"] if ENV["DEEPSEEK_API_KEY"]
524
+ c.openai_api_key = ENV["OPENAI_API_KEY"] if ENV["OPENAI_API_KEY"]
525
+ c.openai_api_base = ENV["OPENAI_API_BASE"] if ENV["OPENAI_API_BASE"]
526
+ c.openrouter_api_key = ENV["OPENROUTER_API_KEY"] if ENV["OPENROUTER_API_KEY"]
527
+ c.gemini_api_key = ENV["GEMINI_API_KEY"] if ENV["GEMINI_API_KEY"]
528
+ end
529
+ end
530
+
531
+ # The persona MODEL — the cheap side that plays the customer. Defaults to the
532
+ # platform `utility_model` (the same setting the judges and the moderator read);
533
+ # --persona-model overrides. Refuses when neither is set: never guess a model to
534
+ # spend money on (the proposer's rule, applied to the cheap side too).
535
+ def build_persona_ask(opts, settings)
536
+ model = opts[:persona_model] || settings["utility_model"]
537
+ abort "insika evals:simulate: no persona model — set the platform utility_model or pass --persona-model" if model.to_s.empty?
538
+
539
+ configure_cli_llm!
540
+ # ruby_llm_ask runs with assume_model_exists, which requires an explicit
541
+ # provider — a bare model id (e.g. a platform utility_model outside the
542
+ # RubyLLM registry) aborted every simulated run before this fallback.
543
+ provider = ENV["EVAL_PERSONA_PROVIDER"] || settings["default_provider"]
544
+ Insika::Evals::JudgePanel.ruby_llm_ask(model, provider)
545
+ end
546
+
547
+ def build_sim_judge(opts, settings)
548
+ models = if opts[:judge_model]
549
+ [{ "model" => opts[:judge_model], "provider" => opts[:judge_provider] }]
550
+ else
551
+ Array(settings["evals"] && settings["evals"]["judges"])
552
+ end
553
+ return nil if models.reject { |m| (m["model"] || m[:model]).to_s.empty? }.empty?
554
+
555
+ configure_cli_llm!
556
+ Insika::Evals::JudgePanel.build(settings["evals"] || {}, overrides: {
557
+ "judge_model" => opts[:judge_model], "judge_provider" => opts[:judge_provider]
558
+ })&.first
559
+ end
560
+
561
+ # The pairwise judge — the SAME configured panel, asked which conversation served
562
+ # the customer better. Same opt-in as a replay's --pairwise (2 calls per judge).
563
+ def build_sim_pairwise(opts, settings)
564
+ models = if opts[:judge_model]
565
+ [{ "model" => opts[:judge_model], "provider" => opts[:judge_provider] }]
566
+ else
567
+ Array(settings["evals"] && settings["evals"]["judges"])
568
+ end
569
+ return nil if models.reject { |m| (m["model"] || m[:model]).to_s.empty? }.empty?
570
+
571
+ configure_cli_llm!
572
+ Insika::Evals::JudgePanel.pairwise(settings["evals"] || {}, overrides: {
573
+ "judge_model" => opts[:judge_model], "judge_provider" => opts[:judge_provider]
574
+ })&.first
575
+ end
576
+
577
+ # --target <agent|url>: an agent id rides HttpTransport over the deployment's
578
+ # /v1/responses (the same surface the replay uses); a full http(s) URL is an A2A
579
+ # endpoint (an agent that only speaks A2A) driven by the thin A2A transport. The
580
+ # A2A client is built with a SYNCHRONOUS Net::HTTP adapter (the CLI has no
581
+ # reactor) — `post_json(url, body) -> Hash` is the client's whole contract.
582
+ #
583
+ # POLLING: the A2A client's default cadence (0.02s, 30 polls = ~0.6s total) is
584
+ # for in-process loopback. A remote agent takes SECONDS to finish a task, so the
585
+ # CLI polls once per second, bounded by --timeout (the same flag the HTTP
586
+ # transport's read timeout honors).
587
+ def build_sim_transport(opts)
588
+ target = opts[:target]
589
+ if target.to_s.match?(%r{\Ahttps?://})
590
+ require_relative "../lib/insika/server/a2a/client"
591
+ require_relative "../lib/insika/server/a2a/message"
592
+ require "net/http"
593
+ require "uri"
594
+ cadence = 1.0
595
+ client = Insika::Server::A2A::Client.new(
596
+ http: SyncPostJson.new,
597
+ poll_max: [opts[:timeout].to_i, 1].max,
598
+ sleeper: ->(_delay) { sleep(cadence) }
599
+ )
600
+ Insika::Evals::A2ATransport.new(client: client, url: target)
601
+ else
602
+ Insika::Evals::HttpTransport.new(base_url: opts[:base_url], token: opts[:token], timeout: opts[:timeout])
603
+ end
604
+ end
605
+
606
+ # A synchronous A2A JSON-RPC HTTP adapter for the CLI (no async reactor). The A2A
607
+ # client calls `post_json(url, body) -> Hash`; this answers it over Net::HTTP. An
608
+ # unreachable host or a non-JSON body becomes a clean `{ "error" => ... }` envelope —
609
+ # the A2A client turns that into a turn error, never a crash.
610
+ class SyncPostJson
611
+ def post_json(url, body)
612
+ uri = URI.parse(url)
613
+ req = Net::HTTP::Post.new(uri)
614
+ req["Content-Type"] = "application/json"
615
+ req["Accept"] = "application/json"
616
+ req.body = body.is_a?(String) ? body : JSON.generate(body)
617
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
618
+ open_timeout: 10, read_timeout: 120) { |http| http.request(req) }
619
+ JSON.parse(res.body.to_s)
620
+ rescue StandardError => e
621
+ { "error" => { "code" => -32_600, "message" => "A2A call failed: #{e.class}: #{e.message}" } }
622
+ end
623
+ end
624
+
625
+ def eval_settings_from_env
626
+ db = Insika::EnvSchema.read("INSIKA_DB", ENV)
627
+ return {} if db.nil? || db.empty?
628
+
629
+ Insika::SettingsStore.new(config_store: Insika::ConfigStore.new(store: Insika::Stores::SQLite.new(path: db))).get
630
+ rescue StandardError
631
+ {}
632
+ end
633
+
634
+ def simulate_markdown(report, verdict, pairwise = nil)
635
+ lines = ["# Simulated run — #{report['id']} (#{report['agent']})", "",
636
+ "**simulated: true** · stop: #{report['stop']}#{" (turn #{report['turns']})" if report['turns']}" \
637
+ "#{" · error: #{report['error']}" if report['error']}", ""]
638
+ report["transcript"].each do |m|
639
+ role = m[:role].to_s == "user" ? "customer" : "assistant"
640
+ lines << "**#{role}**"
641
+ lines << m[:text].to_s.empty? ? " _#{m[:error] || 'no reply'}_" : " #{m[:text]}"
642
+ lines << " _tools: #{m[:tools].join(', ')}_" if m[:tools] && !m[:tools].empty?
643
+ end
644
+ lines << ""
645
+ if verdict
646
+ lines << "**judge: #{verdict.pass ? '✅' : '❌'} #{verdict.score}** — #{verdict.reason}"
647
+ else
648
+ lines << "_(no judge configured — transcript only)_"
649
+ end
650
+ if pairwise
651
+ mark = { "better" => "🟢", "comparable" => "🟡", "worse" => "🔴" }.fetch(pairwise.outcome, "·")
652
+ lines << "**vs incumbent (#{pairwise.vs}): #{mark} #{pairwise.outcome}** — #{pairwise.reason}"
653
+ end
654
+ "#{lines.join("\n")}\n"
655
+ end
656
+
657
+ # insika mcp list|add|remove|import|test|refresh
658
+ def mcp(argv)
659
+ sub = argv.shift
660
+ case sub
661
+ when "list" then mcp_list
662
+ when "add" then mcp_add(argv)
663
+ when "remove" then mcp_remove(argv)
664
+ when "import" then mcp_import(argv)
665
+ when "test" then mcp_run(argv, verb: "test", persist_label: nil)
666
+ when "refresh" then mcp_run(argv, verb: "refresh", persist_label: "cached")
667
+ else
668
+ warn "insika mcp: unknown subcommand '#{sub}' (list|add|remove|import|test|refresh)"
669
+ exit 2
670
+ end
671
+ rescue Insika::Error => e
672
+ warn "insika mcp: #{e.message}"
673
+ exit 2
674
+ end
675
+
676
+ def mcp_list
677
+ store = mcp_store
678
+ names = store.names
679
+ return puts("no MCP instances configured (insika mcp add / insika mcp import FILE.json)") if names.empty?
680
+
681
+ names.each do |name|
682
+ r = store.get(name)
683
+ status = r["enabled"] ? "enabled" : "disabled"
684
+ target = r["transport"] == "stdio" ? r["command"].to_s : r["url"].to_s
685
+ puts format("%-20s %-8s %-8s %-40s %d tool(s)", name, r["transport"], status, target, Array(r["tools_cache"]).size)
686
+ end
687
+ end
688
+
689
+ def mcp_add(argv)
690
+ opts = { args: [], headers: {}, env: {}, enabled: true }
691
+ OptionParser.new do |o|
692
+ o.banner = "Usage: insika mcp add --name NAME [--transport stdio|http|sse] [options]"
693
+ o.on("--name NAME", "instance name (required)") { |v| opts[:name] = v }
694
+ o.on("--transport T", "stdio|http|sse (default: stdio with --command, else http)") { |v| opts[:transport] = v }
695
+ o.on("--command CMD", "stdio: the executable") { |v| opts[:command] = v }
696
+ o.on("--arg ARG", "stdio: one argv entry (repeatable)") { |v| opts[:args] << v }
697
+ o.on("--url URL", "http/sse: the server URL") { |v| opts[:url] = v }
698
+ o.on("--header 'Name: value'", "http/sse: one header (repeatable)") do |v|
699
+ k, val = v.split(":", 2)
700
+ opts[:headers][k.to_s.strip] = val.to_s.strip
701
+ end
702
+ o.on("--env 'KEY=value'", "stdio: one env var (repeatable)") do |v|
703
+ k, val = v.split("=", 2)
704
+ opts[:env][k.to_s.strip] = val.to_s.strip
705
+ end
706
+ o.on("--description DESC", "free text") { |v| opts[:description] = v }
707
+ o.on("--disabled", "create it disabled") { opts[:enabled] = false }
708
+ end.parse!(argv)
709
+ raise Insika::ValidationError, "--name is required" if opts[:name].nil?
710
+
711
+ opts[:transport] ||= Insika::Coercion.presence(opts[:command]) ? "stdio" : "http"
712
+ record = mcp_store.upsert(opts)
713
+ puts "MCP instance '#{record['name']}' saved (#{record['transport']}, #{record['enabled'] ? 'enabled' : 'disabled'})"
714
+ end
715
+
716
+ def mcp_remove(argv)
717
+ name = argv.shift
718
+ raise Insika::ValidationError, "usage: insika mcp remove NAME" if name.nil?
719
+
720
+ existed = mcp_store.delete(name)
721
+ puts existed ? "MCP instance '#{name}' removed" : "MCP instance '#{name}' did not exist"
722
+ end
723
+
724
+ def mcp_import(argv)
725
+ path = argv.shift
726
+ raise Insika::ValidationError, "usage: insika mcp import FILE.json" if path.nil?
727
+ raise Insika::ValidationError, "no such file: #{path}" unless File.exist?(path)
728
+
729
+ records = Insika::McpJson.import(File.read(path), mcp_store: mcp_store)
730
+ puts "imported #{records.size} MCP instance(s) from #{path}"
731
+ records.each { |r| puts " #{r['name']} (#{r['transport']})" }
732
+ end
733
+
734
+ # Shared by `test`/`refresh` — both connect live and list tools
735
+ # (Insika::McpToolRegistry#refresh always writes tools_cache; `test` differs
736
+ # only in what it prints). Exits 1 (not 2) on a
737
+ # connection/transport failure — the instance is configured correctly, the
738
+ # SERVER is unreachable, which is a finding, not a usage error. Rescues
739
+ # StandardError, not just Insika::Error: the registry's docstring is
740
+ # explicit that a transport failure is the GEM's error class (e.g.
741
+ # ruby_llm-mcp's own AuthenticationRequiredError) and it is this caller's
742
+ # job to turn it into a clean one-liner instead of a raw backtrace.
743
+ def mcp_run(argv, verb:, persist_label:)
744
+ name = argv.shift
745
+ raise Insika::ValidationError, "usage: insika mcp #{verb} NAME" if name.nil?
746
+
747
+ tools = mcp_registry.refresh(name)
748
+ suffix = persist_label ? ", #{persist_label}" : ""
749
+ puts "MCP instance '#{name}': connected, #{tools.size} tool(s)#{suffix}"
750
+ tools.each { |t| puts " #{t['name']} — #{t['description']}" }
751
+ rescue StandardError => e
752
+ warn "MCP instance '#{name}': #{e.message}"
753
+ exit 1
754
+ end
755
+
756
+ # insika new --list | insika new <template> [dir]
757
+ def new_from_template(argv)
758
+ if argv.first == "--list"
759
+ new_list
760
+ else
761
+ new_copy(argv)
762
+ end
763
+ rescue Insika::Error => e
764
+ warn "insika new: #{e.message}"
765
+ exit 2
766
+ end
767
+
768
+ def new_list
769
+ entries = Insika::Templates.all
770
+ return puts("no templates available") if entries.empty?
771
+
772
+ entries.each { |t| puts format("%-18s %-10s %s", t.name, t.trail, t.description) }
773
+ end
774
+
775
+ # Copies the template's directory verbatim (agent.rb + README.md) — no
776
+ # rewriting, the generated script IS the editing surface. Prints the
777
+ # exact run line, including any env the template needs SET (not just
778
+ # available as an override, like MCP_URL's keyless default) — the
779
+ # EgressGuard/stdio-gate failure is silent otherwise, same discipline as
780
+ # the `mcp add`/data-tool egress hints elsewhere in this CLI.
781
+ def new_copy(argv)
782
+ name = argv.shift
783
+ raise Insika::ValidationError, "usage: insika new <template> [dir] (see insika new --list)" if name.nil?
784
+
785
+ entry = Insika::Templates.read(name)
786
+ dest = argv.shift || "./#{name}"
787
+ raise Insika::ValidationError, "already exists: #{dest}" if File.exist?(dest)
788
+
789
+ FileUtils.mkdir_p(dest)
790
+ FileUtils.cp(Insika::Templates.agent_path(name), File.join(dest, "agent.rb"))
791
+ readme = Insika::Templates.readme_path(name)
792
+ FileUtils.cp(readme, File.join(dest, "README.md")) if File.file?(readme)
793
+
794
+ puts "Created #{dest}/ (#{entry.title})"
795
+ puts
796
+ puts "Run it:"
797
+ env_flags = entry.env.map { |e| "#{e}=1" }.join(" ")
798
+ env_flags = "#{env_flags} " unless env_flags.empty?
799
+ puts " #{env_flags}DEEPSEEK_API_KEY=sk-... ruby #{dest}/agent.rb"
800
+ puts "\nRequires: #{entry.requires}" if entry.requires
801
+ end
802
+
803
+ # insika demo:seed [--force]
804
+ #
805
+ # Populates the "demo-store" agent with enough data to see every Studio
806
+ # loop working at once: a funnel with a frozen baseline, follow-ups in
807
+ # all four states, refinement runs across the lifecycle, pending +
808
+ # resolved approvals, distillation proposals/facts, and a golden set
809
+ # with a baseline (Insika::Demo::Seeder — the same object the Studio's
810
+ # "Seed demo data" button dispatches). Safe against a live volume: it
811
+ # only ever touches the demo agent's own records, and is a no-op on a
812
+ # second run unless --force asks for another batch.
813
+ def demo_seed(argv)
814
+ opts = { force: false }
815
+ OptionParser.new do |o|
816
+ o.banner = "Usage: insika demo:seed [--force]"
817
+ o.on("--force", "seed again even if the demo agent already exists") { opts[:force] = true }
818
+ end.parse!(argv)
819
+
820
+ backend = Insika::Wiring::Graph.backend_from_env
821
+ config_store = Insika::ConfigStore.new(store: backend)
822
+ profiles = Insika::StoredProfileSource.new(config_store: config_store)
823
+ seeder = Insika::Demo::Seeder.new(
824
+ profiles: profiles, store: backend,
825
+ session_store: Insika::SessionStore.new(store: backend),
826
+ task_store: Insika::TaskStore.new(store: backend),
827
+ outcome_store: Insika::OutcomeStore.new(store: backend),
828
+ funnel_store: Insika::FunnelStore.new(store: backend),
829
+ followup_store: Insika::FollowupStore.new(store: backend),
830
+ refinement_store: Insika::RefinementStore.new(store: backend),
831
+ pending_action_store: Insika::PendingActionStore.new(store: backend),
832
+ proposal_store: Insika::ProposalStore.new(store: backend),
833
+ memory_store: Insika::MemoryStore.new(store: backend),
834
+ golden_store: Insika::GoldenStore.new(config_store: config_store),
835
+ baseline_store: Insika::BaselineStore.new(config_store: config_store),
836
+ event_stream: Insika::EventStream.new
837
+ )
838
+ result = seeder.seed!(force: opts[:force])
839
+
840
+ unless result[:seeded]
841
+ warn "insika demo:seed: already seeded (#{result[:reason]}) — pass --force to seed again."
842
+ return
843
+ end
844
+
845
+ puts "seeded demo agent '#{result[:agent]}':"
846
+ result[:counts].each { |area, n| puts " #{area}: #{n}" }
847
+ puts "\nOpen /studio/funnel?agent=#{result[:agent]} (also /studio/followups, " \
848
+ "/studio/refinement, /studio/approvals, /studio/harvest, /studio/evals) in " \
849
+ "whichever Studio instance points at this INSIKA_DB."
850
+ rescue Insika::Error => e
851
+ warn "insika demo:seed: #{e.message}"
852
+ exit 2
853
+ end
854
+
276
855
  # -- helpers -------------------------------------------------------
277
856
 
857
+ def mcp_store
858
+ backend = Insika::Wiring::Graph.backend_from_env
859
+ Insika::McpStore.new(config_store: Insika::ConfigStore.new(store: backend))
860
+ end
861
+
862
+ def mcp_registry
863
+ Insika::McpToolRegistry.new(mcp_store: mcp_store)
864
+ end
865
+
278
866
  def baseline_store
279
867
  backend = Insika::Wiring::Graph.backend_from_env
280
868
  Insika::BaselineStore.new(config_store: Insika::ConfigStore.new(store: backend))
@@ -405,6 +993,102 @@ end
405
993
  exit 1
406
994
  end
407
995
 
996
+ # insika knowledge:backfill --agent ID [--since ISO8601] [--json]
997
+ #
998
+ # the recovery path (RFC's "re-scan, not a queue"): replays the agent's
999
+ # stored sessions through the SAME extractor a live turn's terminal hook
1000
+ # uses. Writes nothing but the concepts themselves.
1001
+ def knowledge_backfill(argv)
1002
+ opts = { json: false }
1003
+ OptionParser.new do |o|
1004
+ o.banner = "Usage: insika knowledge:backfill --agent ID [options]"
1005
+ o.on("--agent ID", "agent whose sessions to replay (required)") { |v| opts[:agent] = v }
1006
+ o.on("--since ISO8601", "replay only sessions with turns from this instant on") { |v| opts[:since] = v }
1007
+ o.on("--json", "emit the summary as JSON") { opts[:json] = true }
1008
+ end.parse!(argv)
1009
+
1010
+ abort "insika knowledge:backfill: --agent is required" if Insika::Coercion.blank?(opts[:agent])
1011
+
1012
+ result = begin
1013
+ run_knowledge_backfill(opts)
1014
+ rescue Insika::Error, ArgumentError => e
1015
+ warn "insika knowledge:backfill: #{e.message}"
1016
+ exit 2
1017
+ end
1018
+ if opts[:json]
1019
+ require "json"
1020
+ puts JSON.pretty_generate(result)
1021
+ else
1022
+ puts knowledge_backfill_report(result)
1023
+ end
1024
+ end
1025
+
1026
+ # Builds the handler over the real durable backend — same "no app boot"
1027
+ # rule as `doctor`/`harvest`: no DEEPSEEK, no seeding, no reactor.
1028
+ def run_knowledge_backfill(opts)
1029
+ configure_cli_llm!
1030
+ backend = Insika::Wiring::Graph.backend_from_env
1031
+ config_store = Insika::ConfigStore.new(store: backend)
1032
+ profiles = Insika::StoredProfileSource.new(config_store: config_store)
1033
+ handler = Insika::Commands::BackfillKnowledge.new(
1034
+ profiles: profiles,
1035
+ knowledge_store: Insika::KnowledgeStore.new(store: backend),
1036
+ session_store: Insika::SessionStore.new(store: backend),
1037
+ task_store: Insika::TaskStore.new(store: backend),
1038
+ settings_store: Insika::SettingsStore.new(config_store: config_store),
1039
+ event_stream: Insika::EventStream.new
1040
+ )
1041
+ handler.call(Insika::Command.build(:backfill_knowledge,
1042
+ { agent: opts[:agent], since: opts[:since] }.compact, transport: :cli))
1043
+ end
1044
+
1045
+ def knowledge_backfill_report(result)
1046
+ return "knowledge:backfill: skipped (#{result[:skipped]})" unless result[:backfilled]
1047
+
1048
+ head = "knowledge:backfill: #{result[:concepts]} concept(s) learned from #{result[:sessions]} session(s)"
1049
+ head += " — #{result[:conflicts]} conflict(s), needs a human" if result[:conflicts].to_i.positive?
1050
+ dropped = result[:dropped].reject { |_k, v| v.to_i.zero? }
1051
+ dropped.empty? ? head : "#{head} — dropped: #{dropped.map { |k, v| "#{k}: #{v}" }.join(', ')}"
1052
+ end
1053
+
1054
+ # insika knowledge:export --agent ID [--out DIR] [--tenant T]
1055
+ #
1056
+ # writes one <name>.md per concept — the storage format IS the export
1057
+ # format, so this is a dump, not a converter, for okf-gem/graphify to
1058
+ # read directly. Safe to re-run: unlike evals:export's YAML.dump, there
1059
+ # is nothing lossy here to guard with --force.
1060
+ KNOWLEDGE_EXPORT_FORMATS = %w[md graphml].freeze
1061
+
1062
+ def knowledge_export(argv)
1063
+ opts = { out: ".", format: "md" }
1064
+ OptionParser.new do |o|
1065
+ o.banner = "Usage: insika knowledge:export --agent ID [options]"
1066
+ o.on("--agent ID", "agent whose concepts to export (required)") { |v| opts[:agent] = v }
1067
+ o.on("--out DIR", "destination directory (default: current directory)") { |v| opts[:out] = v }
1068
+ o.on("--tenant T", "explicit tenant scope (default: none)") { |v| opts[:tenant] = v }
1069
+ o.on("--format FORMAT", "md (default, one file per concept) | graphml (one graph file)") { |v| opts[:format] = v }
1070
+ end.parse!(argv)
1071
+
1072
+ abort "insika knowledge:export: --agent is required" if Insika::Coercion.blank?(opts[:agent])
1073
+ unless KNOWLEDGE_EXPORT_FORMATS.include?(opts[:format])
1074
+ abort "insika knowledge:export: --format must be #{KNOWLEDGE_EXPORT_FORMATS.join(' or ')} (got #{opts[:format].inspect})"
1075
+ end
1076
+
1077
+ backend = Insika::Wiring::Graph.backend_from_env
1078
+ store = Insika::KnowledgeStore.new(store: backend)
1079
+ tenant = Insika::Coercion.presence(opts[:tenant])
1080
+
1081
+ if opts[:format] == "graphml"
1082
+ FileUtils.mkdir_p(opts[:out])
1083
+ path = File.join(opts[:out], "knowledge.graphml")
1084
+ File.write(path, store.export_graphml(opts[:agent], tenant: tenant))
1085
+ puts "exported the concept graph to #{path}"
1086
+ else
1087
+ paths = store.export_dir(opts[:agent], opts[:out], tenant: tenant)
1088
+ puts "exported #{paths.size} concept(s) to #{opts[:out]}"
1089
+ end
1090
+ end
1091
+
408
1092
  def run_failed?(result)
409
1093
  backend = Insika::Wiring::Graph.backend_from_env
410
1094
  run = Insika::HarvestStore.new(store: backend).find_run(result[:run_id])
@@ -414,6 +1098,7 @@ end
414
1098
  # Builds the handler over the real durable backend — same "no app boot" rule as
415
1099
  # `doctor`: no DEEPSEEK, no seeding, no reactor.
416
1100
  def run_harvest(opts)
1101
+ configure_cli_llm!
417
1102
  backend = Insika::Wiring::Graph.backend_from_env
418
1103
  config_store = Insika::ConfigStore.new(store: backend)
419
1104
  profiles = Insika::StoredProfileSource.new(config_store: config_store)
@@ -483,6 +1168,41 @@ end
483
1168
  "last #{n} session(s)"
484
1169
  end
485
1170
 
1171
+ # insika tools:report [--agent ID] [--days N] [--json]
1172
+ #
1173
+ # The tool audit: reads the SAME durable backend as `doctor` (tasks →
1174
+ # sessions → tool_traces), no app boot. Read-only — it names never-called
1175
+ # allowlisted tools, high-error tools and stale tools; the operator removes.
1176
+ def tools_report(argv)
1177
+ opts = { json: false, days: Insika::ToolUsageReport::WINDOW_DAYS }
1178
+ OptionParser.new do |o|
1179
+ o.banner = "Usage: insika tools:report [--agent ID] [--days N] [--json]"
1180
+ o.on("--agent ID", "narrow the report to one stored agent") { |v| opts[:agent] = v }
1181
+ o.on("--days N", Integer, "window for error-rate and staleness (default #{opts[:days]})") { |v| opts[:days] = v }
1182
+ o.on("--json", "emit the report as JSON") { opts[:json] = true }
1183
+ end.parse!(argv)
1184
+
1185
+ backend = Insika::Wiring::Graph.backend_from_env
1186
+ config_store = Insika::ConfigStore.new(store: backend)
1187
+ report = Insika::ToolUsageReport.new(
1188
+ task_store: Insika::TaskStore.new(store: backend),
1189
+ tool_trace_store: Insika::ToolTraceStore.new(store: backend),
1190
+ profile_source: Insika::StoredProfileSource.new(config_store: config_store)
1191
+ ).generate(days: opts[:days], agent: opts[:agent])
1192
+
1193
+ if opts[:agent] && report.agents.empty?
1194
+ warn "insika tools:report: no stored agent '#{opts[:agent]}'"
1195
+ exit 2
1196
+ end
1197
+
1198
+ if opts[:json]
1199
+ require "json"
1200
+ puts JSON.pretty_generate(report.to_h)
1201
+ else
1202
+ puts report.to_s
1203
+ end
1204
+ end
1205
+
486
1206
  # Config stores over the real durable backend — no app boot / no DEEPSEEK.
487
1207
  def build_doctor
488
1208
  backend = Insika::Wiring::Graph.backend_from_env
@@ -532,6 +1252,9 @@ end
532
1252
  # CLI reports declarations only).
533
1253
  followup_store: Insika::FollowupStore.new(store: backend),
534
1254
  contact_store: Insika::ContactStore.new(store: backend),
1255
+ # the schedules check reads the rows over the same runtime
1256
+ # backend (nil = the CLI reports declarations only).
1257
+ schedule_store: Insika::ScheduleStore.new(store: backend),
535
1258
  # the distillation check reads the proposal store over
536
1259
  # the same runtime backend (nil = the CLI reports declarations only).
537
1260
  proposal_store: Insika::ProposalStore.new(store: backend),
@@ -544,6 +1267,8 @@ end
544
1267
  rescue Insika::ConfigError, Insika::ValidationError
545
1268
  nil
546
1269
  end,
1270
+ # the mcp check reads the instances over the same runtime backend.
1271
+ mcp_store: Insika::McpStore.new(config_store: config_store),
547
1272
  backend: backend
548
1273
  )
549
1274
  end