insika 0.2.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  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 +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  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 +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -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 +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  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 +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "zlib"
7
+ require "fileutils"
8
+ require "time"
9
+ require "optparse"
10
+
11
+ module Insika
12
+ module Soak
13
+ # The arrival-rate runner: sustain a declared load against a
14
+ # live deploy for N hours, poll vitals hourly, append every observation to
15
+ # the results file as it happens. It computes NO verdict (C5 does, offline)
16
+ # and never retries a turn — a failed turn is evidence, not a problem to
17
+ # smooth over. Threads, not fibers: the tool that measures the reactor
18
+ # should not share its failure modes.
19
+ class Runner
20
+ # One failed precondition, named (P1..P5 per the techspec §7.2).
21
+ Failure = Data.define(:check, :message)
22
+
23
+ # Preflight refused the run. The failures name what was wrong; nothing
24
+ # was written and no traffic past the probes was fired.
25
+ class PreflightError < Insika::Error
26
+ attr_reader :failures
27
+
28
+ def initialize(failures)
29
+ @failures = failures
30
+ super(failures.map(&:message).join("; "))
31
+ end
32
+ end
33
+
34
+ USAGE = <<~TXT
35
+ insika soak — the 72h soak runner
36
+
37
+ Usage: insika soak --run | --verify FILE | --preflight [options]
38
+
39
+ Modes:
40
+ --run fire the soak (mutually exclusive with --verify)
41
+ --verify FILE re-read an archived run; NO traffic, no network
42
+ --preflight run every precondition check and exit
43
+ --dry-run print the plan + one sample request, send nothing
44
+
45
+ Options:
46
+ --envelope PATH the frozen envelope (required)
47
+ --agent ID the soak agent (default: from envelope)
48
+ --out DIR where the results file lands (default: soak-out)
49
+ --resume FILE continue an interrupted run, recording the gap
50
+
51
+ Environment:
52
+ INSIKA_URL base URL of the engine (default: http://localhost:9292)
53
+ OPENCLAW_GATEWAY_TOKEN Bearer; falls back to ADMIN_TOKEN, then "local-demo"
54
+ TXT
55
+
56
+ # Poisson inter-arrival seconds: `-mean * Math.log(1.0 - rand)`. Seeded,
57
+ # so a re-run with the same seed is the same arrival sequence. The rate is
58
+ # expressed in the ARRIVAL unit (sessions per hour when session_turns > 1);
59
+ # the envelope's turns_per_hour is divided by session_turns before this.
60
+ def self.arrivals(seed:, rate_per_hour:, count:)
61
+ rng = Random.new(seed)
62
+ mean = 3600.0 / rate_per_hour
63
+ Array.new(count) { -mean * Math.log(1.0 - rng.rand) }
64
+ end
65
+
66
+ # CLI entry (bin/insika delegates here). -> exit status.
67
+ def self.main(argv, stdout: $stdout, stderr: $stderr, env: ENV, http: nil)
68
+ opts = { out: "soak-out" }
69
+ modes = []
70
+ OptionParser.new do |o|
71
+ o.banner = "Usage: insika soak --run | --verify FILE | --preflight [options]"
72
+ o.on("-h", "--help", "show this help") { stdout.puts USAGE; return 0 }
73
+ o.on("--run", "fire the soak") { modes << :run }
74
+ o.on("--verify FILE", "re-read an archived run") { |v| modes << [:verify, v] }
75
+ o.on("--preflight", "run every precondition check") { modes << :preflight }
76
+ o.on("--dry-run", "print the plan, send nothing") { modes << :dry_run }
77
+ o.on("--envelope PATH") { |v| opts[:envelope] = v }
78
+ o.on("--agent ID") { |v| opts[:agent] = v }
79
+ o.on("--out DIR") { |v| opts[:out] = v }
80
+ o.on("--resume FILE") { |v| opts[:resume] = v }
81
+ end.parse!(argv)
82
+
83
+ if modes.length != 1
84
+ stderr.puts "insika soak: choose exactly one of --run, --verify, --preflight, --dry-run\n\n#{USAGE}"
85
+ return 2
86
+ end
87
+ if opts[:envelope].nil?
88
+ stderr.puts "insika soak: --envelope PATH is required (the deployment's frozen envelope)\n\n#{USAGE}"
89
+ return 2
90
+ end
91
+
92
+ mode = modes.first
93
+ if mode.is_a?(Array) && mode.first == :verify
94
+ return verify(mode[1], envelope_path: opts[:envelope], stdout: stdout, stderr: stderr)
95
+ end
96
+
97
+ envelope = Envelope.load(opts[:envelope])
98
+ runner = new(envelope: envelope, agent: opts[:agent], out: opts[:out],
99
+ env: env, http: http, stdout: stdout, stderr: stderr)
100
+
101
+ case mode
102
+ when :dry_run
103
+ stdout.puts runner.dry_run_plan
104
+ 0
105
+ when :preflight
106
+ failures = runner.preflight
107
+ if failures.empty?
108
+ stdout.puts "preflight OK — every precondition holds"
109
+ 0
110
+ else
111
+ failures.each { |f| stderr.puts "preflight #{f.check}: #{f.message}" }
112
+ 2
113
+ end
114
+ when :run
115
+ status = runner.run(resume: opts[:resume], traps: true)
116
+ status == :complete ? 0 : 1
117
+ end
118
+ rescue OptionParser::ParseError => e
119
+ stderr.puts "insika soak: #{e.message}\n\n#{USAGE}"
120
+ 2
121
+ rescue PreflightError => e
122
+ e.failures.each { |f| stderr.puts "preflight #{f.check}: #{f.message}" }
123
+ stderr.puts "insika soak: refusing to start"
124
+ 2
125
+ rescue Insika::ConfigError => e
126
+ stderr.puts "insika soak: #{e.message}"
127
+ 2
128
+ end
129
+
130
+ # Pure fold over the archived file — no traffic, no network, no clock.
131
+ def self.verify(path, envelope_path: nil, stdout: $stdout, stderr: $stderr)
132
+ raise Insika::ConfigError, "soak --verify needs --envelope PATH (the frozen envelope the run was declared with)" if envelope_path.nil?
133
+
134
+ envelope = Envelope.load(envelope_path)
135
+ records = read_records(path)
136
+ result = Report.fold(records, envelope: envelope)
137
+ stdout.puts result.to_s
138
+ result.pass? ? 0 : 1
139
+ rescue Insika::ConfigError => e
140
+ stderr.puts "insika soak: #{e.message}"
141
+ 2
142
+ end
143
+
144
+ # Lazy line reader: .jsonl while running, .jsonl.gz once archived. Lines
145
+ # that fail to parse travel through RAW so the fold can count them
146
+ # (a truncated file must not read as a clean one).
147
+ def self.read_records(path)
148
+ lines = if path.end_with?(".gz")
149
+ Zlib::GzipReader.open(path) { |gz| gz.each_line.to_a }
150
+ else
151
+ File.foreach(path).to_a
152
+ end
153
+ lines.map do |line|
154
+ JSON.parse(line)
155
+ rescue JSON::ParserError
156
+ line
157
+ end
158
+ end
159
+
160
+ attr_reader :envelope
161
+
162
+ def initialize(envelope:, out: "soak-out", agent: nil, env: ENV, http: nil,
163
+ seed: nil, clock: nil, sleeper: nil, stdout: $stdout, stderr: $stderr)
164
+ @envelope = envelope
165
+ @out = out
166
+ @agent = agent || envelope[:agent]
167
+ @env = env
168
+ @http = http || Http.new(token: resolve_token(env))
169
+ @seed = seed || Time.now.to_i
170
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
171
+ @sleeper = sleeper || ->(s) { sleep s }
172
+ @stdout = stdout
173
+ @stderr = stderr
174
+ @stop_reason = nil
175
+ @lanes = []
176
+ @in_flight = 0
177
+ @mutex = Mutex.new
178
+ @write_mutex = Mutex.new
179
+ @cond = ConditionVariable.new
180
+ @vitals_failures = 0
181
+ @vitals_degraded_noted = false
182
+ end
183
+
184
+ def target_url
185
+ (@env["INSIKA_URL"] || @env["HARNESS_URL"] || "http://localhost:9292").sub(%r{/$}, "")
186
+ end
187
+
188
+ def corpus
189
+ path = @envelope[:corpus]
190
+ raise Insika::ConfigError, "soak envelope declares no corpus" if path.to_s.empty?
191
+
192
+ lines = File.readlines(path, chomp: true).map(&:strip).reject(&:empty?)
193
+ raise Insika::ConfigError, "soak corpus #{path} has no usable lines" if lines.empty?
194
+
195
+ lines
196
+ rescue Errno::ENOENT
197
+ raise Insika::ConfigError, "soak corpus not found: #{path}"
198
+ end
199
+
200
+ # The five preconditions (techspec §7.2). Each is a REFUSAL TO START, not
201
+ # a runtime behaviour: a soak must not measure its own limiter, a wrong
202
+ # deploy, or a box that hides what it runs on.
203
+ def preflight
204
+ failures = []
205
+ vitals = @http.get_vitals(target_url)
206
+
207
+ if vitals[:status] != 200 || !vitals.dig(:body, "rss_bytes") || vitals.dig(:body, "boot_id").to_s.empty?
208
+ failures << Failure.new("P1", "GET /v1/vitals must return 200 with a non-null rss_bytes and a non-empty boot_id")
209
+ end
210
+
211
+ probe = @http.post_turn(target_url, @agent, user: "soak-preflight", message: "ok",
212
+ timeout: (@envelope[:request_timeout_s] || 120))
213
+ unless probe.dig(:timing).is_a?(Hash) && probe[:timing].any?
214
+ failures << Failure.new("P2", "the probe turn carries no timing block (INSIKA_TURN_TIMING off on the target)")
215
+ end
216
+ unless probe.dig(:usage, "total_tokens").to_i.positive?
217
+ failures << Failure.new("P3", "the probe turn called no model (no usage) — the run would measure the edge limiter")
218
+ end
219
+
220
+ pids = [vitals.dig(:body, "pid")]
221
+ 2.times do
222
+ @sleeper.call(5)
223
+ pids << @http.get_vitals(target_url).dig(:body, "pid")
224
+ end
225
+ if pids.compact.uniq.length != 1
226
+ failures << Failure.new("P4", "vitals.pid is not stable across three polls — the RSS series would be a random worker per hour")
227
+ end
228
+ if @envelope[:web_concurrency] != 1
229
+ failures << Failure.new("P4", "the envelope declares web_concurrency #{@envelope[:web_concurrency].inspect}, not 1")
230
+ end
231
+ unless URI(target_url).host == @envelope[:target_url_host]
232
+ failures << Failure.new("P5", "target host #{URI(target_url).host.inspect} does not match the envelope's #{@envelope[:target_url_host].inspect}")
233
+ end
234
+ if @envelope[:agent] && @agent != @envelope[:agent]
235
+ failures << Failure.new("P5", "agent #{@agent.inspect} does not match the envelope's #{@envelope[:agent].inspect}")
236
+ end
237
+ failures
238
+ end
239
+
240
+ def dry_run_plan
241
+ lines = [
242
+ "soak dry-run — no requests sent",
243
+ " envelope: #{@envelope[:target]} (sha #{@envelope.sha})",
244
+ " target: #{target_url}",
245
+ " agent: #{@agent}",
246
+ " shape: #{@envelope[:turns_per_hour]} turns/h poisson, #{@envelope[:session_turns]}-turn sessions, " \
247
+ "cap #{@envelope[:concurrency_cap]}, #{@envelope[:duration_hours]}h (#{@envelope[:warmup_hours]}h warmup)",
248
+ " sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=openclaw:#{@agent} user=soak-1",
249
+ " out: #{@out}/"
250
+ ]
251
+ unless @envelope.calibrated?
252
+ lines << " NOTE: not calibrated — a #{@envelope[:duration_hours]}h run will refuse to start (E1 first)"
253
+ end
254
+ lines.join("\n")
255
+ end
256
+
257
+ # Fires the soak. -> :complete | :interrupted | :aborted.
258
+ def run(resume: nil, traps: false)
259
+ unless @envelope.calibrated? || @envelope.dry_run?
260
+ raise Insika::ConfigError, "the #{@envelope[:duration_hours]}h run refuses to start: " \
261
+ "the envelope is not calibrated (run E1 first, then write the three ceilings)"
262
+ end
263
+
264
+ failures = preflight
265
+ raise PreflightError, failures unless failures.empty?
266
+
267
+ install_traps if traps
268
+
269
+ header, lane_id = resume ? resume_state(resume) : [new_header, 0]
270
+ path = resume || File.join(@out, "#{header['run_id']}.jsonl")
271
+ FileUtils.mkdir_p(File.dirname(path)) unless resume
272
+ @file = File.open(path, "a")
273
+ if resume
274
+ append_gap_record(resume)
275
+ else
276
+ append(header)
277
+ end
278
+
279
+ run_id = header["run_id"]
280
+ @stdout.puts "soak -> target=#{target_url} agent=#{@agent} duration=#{@envelope[:duration_hours]}h " \
281
+ "turns=#{@envelope[:turns_per_hour]}/h seed=#{@seed} out=#{path}"
282
+ @stdout.puts "deploys are frozen for the window: any restart (boot_id or pid change) fails the run"
283
+
284
+ duration_hours = @envelope[:duration_hours]
285
+ offset_hours = resume ? elapsed_hours(header["started_at"]) : 0
286
+ start = @clock.call
287
+ deadline = start + (duration_hours - offset_hours) * 3600.0
288
+ # The envelope declares TURNS per hour; each arrival is a SESSION of
289
+ # session_turns turns, so arrivals arrive at turns_per_hour/session_turns
290
+ # per hour — otherwise a 60/h x 7-turn envelope fires 420 turns/h.
291
+ sessions_per_hour = @envelope[:turns_per_hour].to_f / @envelope[:session_turns]
292
+ arrivals = self.class.arrivals(seed: @seed, rate_per_hour: sessions_per_hour,
293
+ count: (sessions_per_hour * duration_hours).ceil)
294
+ messages = corpus
295
+ next_turn = start
296
+ snapshot_index = 1
297
+ turn_index = 0
298
+
299
+ loop do
300
+ break if @stop_reason
301
+
302
+ now = @clock.call
303
+ # Hour 1 is due at start+3600 — the LAST snapshot (hour 72) is due
304
+ # exactly at the deadline, so the snapshot fires before the loop
305
+ # ends. Freshly computed from the index, never accumulated (float
306
+ # drift must not eat the final hour).
307
+ snapshot_due = start + snapshot_index * 3600.0
308
+ if now >= snapshot_due - 1e-9 && snapshot_due <= deadline + 1e-9
309
+ append_snapshot(offset_hours + snapshot_index)
310
+ snapshot_index += 1
311
+ next
312
+ end
313
+ break if now >= deadline
314
+
315
+ # All scheduled arrivals consumed: nothing turn-shaped is ever due
316
+ # again, so neither the spawn branch nor the wait math may keep
317
+ # waking on next_turn.
318
+ next_turn = Float::INFINITY if turn_index >= arrivals.length
319
+
320
+ if now >= next_turn && next_turn <= deadline
321
+ spawn_lane(lane_id, turn_index, messages)
322
+ @lanes.reject! { |t| !t.alive? }
323
+ lane_id += 1
324
+ next_turn += arrivals[turn_index]
325
+ turn_index += 1
326
+ next
327
+ end
328
+
329
+ wait = [[snapshot_due, next_turn].min, deadline].min - now
330
+ break if wait <= 0
331
+
332
+ @sleeper.call(wait)
333
+ end
334
+
335
+ @lanes.each(&:join)
336
+ reason = @stop_reason || "complete"
337
+ append({ "t" => "end", "at" => Time.now.utc.iso8601, "reason" => reason })
338
+ @file.close
339
+ archive_path = gzip!(path)
340
+
341
+ result = Report.fold(self.class.read_records(archive_path), envelope: @envelope)
342
+ @stdout.puts result.to_s
343
+ reason == "complete" ? :complete : :interrupted
344
+ end
345
+
346
+ private
347
+
348
+ def resolve_token(env) = env["OPENCLAW_GATEWAY_TOKEN"] || env["ADMIN_TOKEN"] || "local-demo"
349
+
350
+ def install_traps
351
+ %w[INT TERM].each { |sig| Signal.trap(sig) { @stop_reason = "interrupted" } }
352
+ end
353
+
354
+ def new_header
355
+ started = Time.now.utc.iso8601
356
+ {
357
+ "t" => "header", "run_id" => "#{@envelope[:target]}-#{started.tr(':', '-')}",
358
+ "envelope_sha" => @envelope.sha, "envelope" => @envelope.values,
359
+ "target_url" => target_url, "agent" => @agent,
360
+ "seed" => @seed, "runner_version" => "insika #{Insika::VERSION}",
361
+ "started_at" => started
362
+ }
363
+ end
364
+
365
+ # The header plus the next free lane id: lane ids are per-user session
366
+ # ids on the target, so a resume MUST NOT reuse them — a lane 0 that
367
+ # existed before the gap would continue a conversation, not start one.
368
+ def resume_state(path)
369
+ header = nil
370
+ last_lane = 0
371
+ File.foreach(path) do |line|
372
+ record = JSON.parse(line)
373
+ if record["t"] == "header"
374
+ header ||= record
375
+ elsif record["t"] == "turn" && record["lane"].is_a?(Integer)
376
+ last_lane = record["lane"] if record["lane"] >= last_lane
377
+ end
378
+ rescue JSON::ParserError
379
+ next
380
+ end
381
+ raise Insika::ConfigError, "#{path} has no header record" unless header
382
+
383
+ [header, last_lane + 1]
384
+ end
385
+
386
+ def elapsed_hours(started_iso)
387
+ [(Time.now.utc - Time.parse(started_iso)) / 3600, 0].max.floor
388
+ end
389
+
390
+ def append_gap_record(path)
391
+ last_at = self.class.read_records(path).filter_map { |r| r.is_a?(Hash) ? r["at"] : nil }.compact.last
392
+ to = Time.now.utc
393
+ from = Time.parse(last_at) if last_at
394
+ append({ "t" => "gap", "from" => (from || to).iso8601, "to" => to.iso8601,
395
+ "seconds" => (to - (from || to)).round, "reason" => "resume" })
396
+ end
397
+
398
+ def append(record)
399
+ @write_mutex.synchronize do
400
+ @file.puts(JSON.generate(record))
401
+ @file.fsync
402
+ end
403
+ end
404
+
405
+ def append_snapshot(hour)
406
+ vitals = @http.get_vitals(target_url)
407
+ if vitals[:status] == 200 && vitals[:body].is_a?(Hash)
408
+ @vitals_failures = 0
409
+ append({ "t" => "snapshot", "at" => Time.now.utc.iso8601, "hour" => hour,
410
+ "envelope_sha" => @envelope.sha, "vitals" => vitals[:body], "error" => nil })
411
+ else
412
+ @vitals_failures += 1
413
+ append({ "t" => "snapshot", "at" => Time.now.utc.iso8601, "hour" => hour,
414
+ "envelope_sha" => @envelope.sha, "vitals" => nil, "error" => "vitals poll failed" })
415
+ if @vitals_failures >= 3 && !@vitals_degraded_noted
416
+ @vitals_degraded_noted = true
417
+ append({ "t" => "note", "at" => Time.now.utc.iso8601, "note" => "vitals_degraded" })
418
+ end
419
+ end
420
+ end
421
+
422
+ def spawn_lane(lane_id, turn_index, messages)
423
+ @lanes << Thread.new do
424
+ queued_at = @clock.call
425
+ @mutex.synchronize do
426
+ @cond.wait(@mutex, 0.2) while @in_flight >= @envelope[:concurrency_cap]
427
+ @in_flight += 1
428
+ end
429
+ queued_ms = ((@clock.call - queued_at) * 1000).round
430
+ begin
431
+ @envelope[:session_turns].times do |step|
432
+ # step advances the corpus read: turn 7 of a session must not
433
+ # repeat turn 1's line.
434
+ line = messages[(turn_index + step) % messages.length]
435
+ result = @http.post_turn(target_url, @agent, user: "soak-#{lane_id}", message: line,
436
+ timeout: (@envelope[:request_timeout_s] || 120))
437
+ append({
438
+ "t" => "turn", "at" => Time.now.utc.iso8601, "lane" => lane_id, "step" => step + 1,
439
+ "corpus_line" => ((turn_index + step) % messages.length) + 1,
440
+ "ok" => result[:ok] == true, "status" => result[:status],
441
+ "queued_ms" => queued_ms, "ttfb_ms" => result[:ttfb_ms],
442
+ "total_ms" => result[:total_ms], "timing" => result[:timing],
443
+ "usage" => result[:usage], "error" => result[:error]
444
+ })
445
+ end
446
+ ensure
447
+ @mutex.synchronize do
448
+ @in_flight -= 1
449
+ @cond.signal
450
+ end
451
+ end
452
+ end
453
+ end
454
+
455
+ def gzip!(path)
456
+ gz_path = "#{path}.gz"
457
+ Zlib::GzipWriter.open(gz_path) { |gz| File.open(path, "rb") { |f| IO.copy_stream(f, gz) } }
458
+ File.delete(path)
459
+ gz_path
460
+ end
461
+
462
+ # stdlib HTTP transport, deliberately Net::HTTP — the same choice
463
+ # loadtest.rb made, and the runner measures the reactor from outside it.
464
+ class Http
465
+ def initialize(token:)
466
+ @token = token
467
+ end
468
+
469
+ def get_vitals(base_url)
470
+ uri = URI.join(base_url + "/", "v1/vitals")
471
+ req = Net::HTTP::Get.new(uri)
472
+ req["Authorization"] = "Bearer #{@token}"
473
+ http = Net::HTTP.new(uri.host, uri.port)
474
+ http.use_ssl = uri.scheme == "https"
475
+ http.open_timeout = 10
476
+ http.read_timeout = 30
477
+ res = http.request(req)
478
+ { status: res.code.to_i, body: safe_json(res.body) }
479
+ rescue StandardError => e
480
+ { status: nil, body: nil, error: e.class.to_s }
481
+ end
482
+
483
+ # One turn: streaming POST /v1/responses; measures TTFB/total and
484
+ # extracts the last usage + timing frames. No retry — a failed turn is
485
+ # evidence.
486
+ def post_turn(base_url, agent, user:, message:, timeout:)
487
+ uri = URI.join(base_url + "/", "v1/responses")
488
+ req = Net::HTTP::Post.new(uri)
489
+ req["Authorization"] = "Bearer #{@token}"
490
+ req["Content-Type"] = "application/json"
491
+ req["Accept"] = "text/event-stream"
492
+ req.body = JSON.generate(model: "openclaw:#{agent}", user: user, stream: true, input: message)
493
+
494
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
495
+ ttfb = nil
496
+ usage = nil
497
+ timing = nil
498
+ http = Net::HTTP.new(uri.host, uri.port)
499
+ http.use_ssl = uri.scheme == "https"
500
+ http.read_timeout = timeout
501
+ http.open_timeout = 10
502
+
503
+ status = nil
504
+ http.start do
505
+ http.request(req) do |res|
506
+ status = res.code.to_i
507
+ return { ok: false, status: status, error: "http #{status}" } unless status.between?(200, 299)
508
+
509
+ buffer = +""
510
+ res.read_body do |chunk|
511
+ ttfb ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
512
+ buffer << chunk
513
+ while (i = buffer.index("\n\n"))
514
+ frame = buffer.slice!(0..i + 1)
515
+ frame.each_line do |line|
516
+ next unless line.start_with?("data:")
517
+
518
+ payload = line.sub(/^data:\s*/, "").strip
519
+ next if payload.empty? || payload == "[DONE]"
520
+
521
+ begin
522
+ obj = JSON.parse(payload)
523
+ u = obj.dig("response", "usage") || obj["usage"]
524
+ usage = u if u
525
+ tm = obj.dig("response", "timing")
526
+ timing = tm if tm
527
+ rescue JSON::ParserError
528
+ nil
529
+ end
530
+ end
531
+ end
532
+ end
533
+ end
534
+ end
535
+ { ok: true, status: status, ttfb_ms: (ttfb || 0) * 1000.0,
536
+ total_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000.0,
537
+ usage: usage, timing: timing, error: nil }
538
+ rescue ::Timeout::Error
539
+ { ok: false, status: status, error: "timeout" }
540
+ rescue StandardError => e
541
+ { ok: false, status: status, error: e.class.to_s }
542
+ end
543
+
544
+ private
545
+
546
+ def safe_json(body)
547
+ JSON.parse(body)
548
+ rescue JSON::ParserError
549
+ nil
550
+ end
551
+ end
552
+ end
553
+ end
554
+ end
@@ -69,14 +69,32 @@ module Insika
69
69
  inject! if @seen >= @expected
70
70
  end
71
71
 
72
+ # Tail-appends whatever is in the mailbox right now and reports how many. Called
73
+ # at a batch boundary (#inject!) and ONCE MORE by the Executor when the run ended
74
+ # with a message no boundary ever arrived for (a text-only turn closes no batch) —
75
+ # the append, the running count and the `:turn_steered` event belong in one place
76
+ # either way.
77
+ def absorb_pending!
78
+ texts = @actor.take_user_messages!
79
+ return 0 if texts.empty?
80
+
81
+ texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
82
+ @injected += texts.size
83
+ # Counts only, never content — the text is already in the transcript, which is the
84
+ # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
85
+ @emit.call(:turn_steered, { count: texts.size, total: @injected })
86
+ texts.size
87
+ end
88
+
72
89
  private
73
90
 
74
91
  def open_batch(message)
75
92
  calls = field(message, :tool_calls)
76
93
  size = calls.respond_to?(:size) ? calls.size : 0
77
94
  # A message with no tool call is the model talking, not a batch: leave any
78
- # pending message where it is (the turn is about to end, and the Executor
79
- # releases it as a follow-up turn rather than answering it half-way).
95
+ # pending message where it is. The turn is about to end, and the Executor
96
+ # absorbs it there in ONE extra round (#absorb_pending!) a text-only turn
97
+ # closes no batch, and answering it half-way is not an option.
80
98
  return @expected = nil if size.zero?
81
99
 
82
100
  @expected = size
@@ -88,14 +106,7 @@ module Insika
88
106
  @expected = nil
89
107
  return if @halted # nothing will read it: leave it in the mailbox
90
108
 
91
- texts = @actor.take_user_messages!
92
- return if texts.empty?
93
-
94
- texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
95
- @injected += texts.size
96
- # Counts only, never content — the text is already in the transcript, which is the
97
- # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
98
- @emit.call(:turn_steered, { count: texts.size, total: @injected })
109
+ absorb_pending!
99
110
  end
100
111
 
101
112
  def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
data/lib/insika/store.rb CHANGED
@@ -17,10 +17,13 @@ module Insika
17
17
  # normalizes at the boundary)
18
18
  # - list(scope) returns only keys of the scope, ordered lexicographically;
19
19
  # prefix filters by start_with?
20
+ # - scopes(prefix) returns the scope NAMES that start with the prefix,
21
+ # ordered lexicographically (an additive enumeration, for domain stores
22
+ # that name cells hierarchically — "memory:acme", "memory:acme:123")
20
23
  # - a nested transaction reuses the outer transaction (no SAVEPOINT)
21
24
  # - a serialization failure on write -> Insika::StoreError (fail-fast)
22
25
  #
23
- # Backends `include Store` and override the five methods; any forgotten
26
+ # Backends `include Store` and override the six methods; any forgotten
24
27
  # method raises NotImplementedError (fail-fast, better than a distant
25
28
  # NoMethodError).
26
29
  module Store
@@ -44,6 +47,12 @@ module Insika
44
47
  raise NotImplementedError, "#{self.class}#list"
45
48
  end
46
49
 
50
+ # -> [String] scope names ordered lexicographically, filtered by prefix
51
+ # (start_with?). Added for domain stores with hierarchical scope names.
52
+ def scopes(prefix = nil)
53
+ raise NotImplementedError, "#{self.class}#scopes"
54
+ end
55
+
47
56
  # -> the block's result; atomic if the backend supports it
48
57
  def transaction(&blk)
49
58
  raise NotImplementedError, "#{self.class}#transaction"
@@ -39,6 +39,12 @@ module Insika
39
39
  prefix ? keys.select { |k| k.start_with?(prefix) } : keys
40
40
  end
41
41
 
42
+ def scopes(prefix = nil)
43
+ names = @data.keys
44
+ names = names.select { |s| s.start_with?(prefix) } if prefix
45
+ names.sort
46
+ end
47
+
42
48
  # Snapshot at the start of the outermost transaction; an exception at any
43
49
  # level -> restore the snapshot and re-propagate (a REAL rollback).
44
50
  # A nested one reuses the outer (no SAVEPOINT).
@@ -128,6 +128,14 @@ module Insika
128
128
  raise Insika::StoreError, e.message
129
129
  end
130
130
 
131
+ def scopes(prefix = nil)
132
+ names = @db.execute("SELECT DISTINCT scope FROM kv ORDER BY scope").map(&:first)
133
+ names = names.select { |s| s.start_with?(prefix) } if prefix
134
+ names
135
+ rescue ::SQLite3::Exception => e
136
+ raise Insika::StoreError, e.message
137
+ end
138
+
131
139
  # BEGIN IMMEDIATE ... COMMIT/ROLLBACK, serialized by the semaphore.
132
140
  # A nested one reuses the outer transaction.
133
141
  def transaction(&blk)